I spent about three months selling an Android app with a free tier, then removed the paid part entirely and put the whole project on GitHub under Apache-2.0.
The interesting part wasn't the decision. It was the removal. Deleting a payment layer from a shipped app touches dependency injection, app startup, navigation, UI, stored user data and your store listing, and most of those failures are silent. No compiler error tells you that a stale entitlement file is still sitting on ten thousand devices.
Here's how I did it, including the parts I got wrong.
What the app was, and what the paid tier gated
Automatist is a workflow app for Android. You build a workflow once out of small actions (fetch a URL, pull an RSS feed, call a REST endpoint, check the weather, get a commute time, reuse a saved note), attach an instruction, and run it manually or on a schedule. Each run produces one output you review before using.
The monetisation was deliberately mild. Free users could have one active workflow. A one time purchase called automatist_pro unlocked unlimited workflows. No subscription, no ads, no consumables.
That single limit turned out to touch a surprising amount of code:
- A
ProductAccessRepositoryinterface with two implementations, one backed by Google Play Billing and one backed by local DataStore for development - A
BillingManagerwrapping BillingClient, purchase flow, restore, and acknowledgement -
PlanTypeandPlanStatemodels - A Hilt module binding the production implementation
- An Upgrade screen, a navigation route to it, and a Pro badge on the dashboard
- Two dialogs, one for hitting the limit and one prompting the upgrade
- A DataStore file,
product_access, persisting entitlement on device - Billing initialisation in
Application.onCreate - R8 keep rules for the billing library
Nine surfaces for one boolean.
Why a removal is a migration, not a delete
My first instinct was to delete BillingManager and fix whatever stopped compiling. That is exactly the wrong order, for two reasons.
The first is dangling references. The Upgrade screen reads a ViewModel that reads a repository that reads the billing client. Delete from the bottom and you spend an afternoon chasing compile errors upward through code you were about to remove anyway.
The second is user data, and this one actually matters. Existing installs have a product_access DataStore file on disk. Someone who paid has pro_unlocked = true in there. Whatever you do next, you must not corrupt or orphan that in a way that breaks their app on upgrade.
I decided early to leave it completely alone. No migration, no cleanup job, no deletion. It just becomes a file nothing opens. Writing cleanup code would have meant creating a fresh code path into billing territory for zero user benefit, and cleanup code that runs once on every device is exactly the kind of thing that goes wrong at 3am.
The only change was to the comment in the backup exclusion rules, so the next person reading it understands why an unused file is still listed there.
The four phases
I worked in four phases, each an independently green commit.
Phase 1: make every feature free. Remove the enforcement, not the machinery. The one workflow limit stops being applied. Billing code still exists and still compiles, it just no longer gates anything.
Phase 2: remove the paid UI. The Upgrade screen, its route, its ViewModel, the Pro badge, and both dialogs. Now nothing in the app can navigate to a purchase.
Phase 3: remove the billing runtime. Now that nothing references it, delete BillingManager, the whole access domain, both repository implementations, the Hilt module, the billing-ktx dependency, its version catalog entry, and the billing R8 keep rules. Remove the billing bootstrap from Application.onCreate.
Phase 4: fix everything outside the code. Policies, store listing, release notes.
The ordering matters. Each phase only removes things that the previous phase already made unreachable, so at no point am I fighting the compiler.
The rule I'd most recommend copying is this line, which I wrote into the plan document before starting:
The intermediate Phase 1 build must not be published as a standalone production release.
Phase 1 compiles. It passes tests. It runs fine. It is also an app that still contains a full billing integration while advertising itself as free, and shipping it would have been a genuinely bad idea. Compiling and being publishable are different properties, and if you don't write that down somewhere, a future you with a deadline will conflate them.
Each phase also got an explicit data guarantee written down. Phase 3's was: no DataStore name or key changed, product_access untouched, Room stays at v17, applicationId unchanged, no user data deleted.
Tests that assert absence
This is the part I think generalises furthest.
A normal test suite verifies that things work. It is completely silent about things that should no longer exist. If someone adds billing-ktx back to the version catalog next year, or a transitive dependency drags BillingClient onto the classpath, every test still passes and nobody notices.
So each removal phase got a test that asserts absence. Reflection against the classpath, not source text scanning, because grepping for strings breaks the moment someone reformats a file:
class BillingRuntimeRemovedTest {
private fun classExists(fqName: String): Boolean =
try {
Class.forName(fqName, false, this::class.java.classLoader)
true
} catch (e: ClassNotFoundException) {
false
}
@Test
fun `google play billing classes are absent from the classpath`() {
assertFalse(classExists("com.android.billingclient.api.BillingClient"))
assertFalse(classExists("com.automatist.app.data.billing.BillingManager"))
}
@Test
fun `product-access entitlement classes are absent`() {
assertFalse(classExists("com.automatist.app.data.access.BillingProductAccessRepository"))
assertFalse(classExists("com.automatist.app.data.access.LocalProductAccessRepository"))
assertFalse(classExists("com.automatist.app.domain.access.ProductAccessRepository"))
assertFalse(classExists("com.automatist.app.domain.access.PlanState"))
assertFalse(classExists("com.automatist.app.di.AccessModule"))
}
@Test
fun `app no longer injects a BillingManager at startup`() {
val fields = AutomatistApp::class.java.declaredFields.map { it.name }
assertFalse("AutomatistApp must not inject billingManager", fields.contains("billingManager"))
// Sanity anchor: the app still injects its real dependencies.
assertTrue(fields.contains("scheduleManager"))
assertTrue(fields.contains("firstRunSeeder"))
}
}
The sanity anchors at the bottom are the detail I'd most encourage stealing. An absence test that only asserts absence passes for the wrong reason if you typo a class name, or if reflection quietly stops seeing anything at all. Asserting that the fields you do expect are still there means the test fails loudly when it stops being meaningful, rather than succeeding forever on a technicality.
The UI equivalent does the same thing for navigation:
@Test
fun `navigation Routes has no Upgrade route`() {
val routeFields = Routes::class.java.declaredFields.map { it.name }
assertFalse("Routes.UPGRADE must not exist", routeFields.contains("UPGRADE"))
// Sanity anchor: real routes are still present.
assertTrue(routeFields.contains("DASHBOARD"))
}
@Test
fun `dashboard no longer exposes a plan-badge state`() {
val methods = DashboardViewModel::class.java.declaredMethods.map { it.name }
assertFalse("DashboardViewModel must not expose planState", methods.contains("getPlanState"))
assertTrue(methods.contains("getRecentRuns"))
}
Unit tests only see the JVM classpath, so I also verified the packaged artifact by hand and recorded it in the commit message rather than in my own memory:
Verified: releaseRuntimeClasspath has no billingclient; merged release manifest has no BILLING permission; release APK dex has no billing classes.
Writing down what you actually checked, rather than what you intended to check, is worth the thirty seconds.
What Play expects when you drop in app purchases
This is where I made my real mistake, and it's the one worth reading if you skim the rest.
Removing billing from the binary is roughly a third of the job. The other two thirds live in the Play Console, and nothing in your build tells you about them:
The store listing description. Mine still had a "FREE AND PRO" section listing "Pro: unlimited workflows, one time purchase". The app had been free for weeks while the store page was still selling an upgrade that no longer existed. I had even written the corrected copy and left it sitting in a file in the repo.
The Data Safety declaration. Still declared Financial info, purchase history collected. There was no billing code left to collect anything. That form is a compliance statement, not marketing copy, and a stale one is worse than an out of date screenshot.
The in-app product itself. automatist_pro still existed in the Console. An active product on an app with no billing integration is what keeps an in app purchases surface alive on your listing.
My advice: treat the Console as part of the migration checklist from the start, in the same document as the code phases. I treated it as an afterthought and it stayed wrong for weeks. The thing that finally caught it was checking the live public listing with curl rather than trusting my memory of having edited it.
Sanitising git history before going public
Making the repo public was a separate exercise from making the app free.
The repository had four years of habits baked into it: local IDE config, build output that had been committed early on before the gitignore matured, a local.properties with a machine specific SDK path, my personal email address on every commit, and some AI tool co-author trailers.
None of that is a security incident. All of it is stuff I'd rather not publish.
What I did:
- Took a backup bundle first, and verified it
- Ran a full history rewrite stripping the build artifacts, IDE config and
local.propertiesfrom every commit - Normalised the personal email addresses to a noreply address
- Removed the tool generated co-author trailers
- Rewrote the one tag, deleted the filter-branch backup refs, and garbage collected. The repo went from about 41 MB to 1.3 MB
- Pushed with explicit per ref
--force-with-lease. Never--force --all. If you rewrite history and then force push every ref at once, you can destroy a branch you forgot existed - Cloned the public remote fresh, audited that clone rather than my local copy, and built it green before trusting it
- Only then flipped the repo to public
Then I turned on secret scanning, push protection, Dependabot alerts, private vulnerability reporting, and branch protection on main.
The step I'd push hardest on: audit a fresh clone, not your working copy. Your local repo has reflogs, stashes and objects that the remote doesn't. Checking it tells you nothing about what a stranger will actually receive.
I also committed the audit report into the repo. It felt slightly odd publishing a document that says "here is everything I removed before you got here", but it's the kind of thing I would want to read about someone else's project.
Splitting the licence three ways
"Open source the app" turned out to mean three different licensing decisions, and conflating them would have been sloppy.
The source code is Apache-2.0. That includes the ordinary Compose UI code, even the strings and class names containing the product name. I made a point of saying so explicitly, because a fork shouldn't have to guess whether the UI source is somehow carved out.
The brand is not granted. The name, logo, launcher icon artwork and store graphics are in the repo because you need them to build the official app, but Apache-2.0 section 6 grants no trademark rights and I wrote a TRADEMARKS.md making that concrete. If you fork and publish, use a different application ID, your own icon, your own signing key, your own identity. Fork the code freely. Don't ship something that looks like my app.
Model weights stay under upstream terms. The app can download a Gemma model at runtime for on device inference. Those weights are not mine, were never committed, and remain under Google's Gemma terms. Slightly confusing to explain in a README, and much worse to get wrong.
Contributions are inbound equals outbound, no CLA. If you contribute, it's Apache-2.0 like everything else.
What I'd do differently
Put the store console in the migration plan. The code phases were carefully ordered and documented. The Console work was a bullet point at the end, and it's the part that stayed broken longest. Anything outside your repo needs to be in the checklist precisely because your tooling can't remind you about it.
Verify the public surface, not the intent. I believed the listing was updated because I remembered editing it. Fetching the live page and grepping it took ten seconds and told me the truth.
Write absence tests at the same time as the removal, not after. I added mine in the same commit, which worked, but it would have been easy to leave them for later and never write them.
Don't leave the changelog to memory. Mine recorded that a particular version code had never been uploaded. Play disagreed when I tried to reuse it. The build system has no idea what you uploaded, so if you don't write it down accurately at the time, you will be wrong about it later.
Decide about plaintext secrets before publishing, not after. The app stores API keys in plaintext DataStore with a TODO to move to the Keystore. That was a defensible shortcut in a closed source app. It reads differently once anyone can go and look at it. I documented it honestly in SECURITY.md instead of quietly hoping nobody would notice, which I think is the right call, but I'd rather have fixed it first.
The project
Automatist is on Google Play, and the source is at github.com/atj393/automatist-android under Apache-2.0. Kotlin, Compose, Hilt, Room, WorkManager, MediaPipe for on device inference.
It currently has one contributor and no CI, so if any of the above sounded like a problem you enjoy, there's plenty of room.
Top comments (0)