Field log · Migration
Migrating 10 apps to target API 36 in one weekend
A complete, unedited account of moving ten of my published apps to Android 16 targeting before the August 31, 2026 Play deadline — the exact version chain, every error I actually hit, and the two that cost me the most time even though neither was a code bug.
When the Play Console notice landed, it landed on ten apps at once. Each one said the same thing: I couldn't submit an update unless it targeted a recent enough Android version, with a hard date of August 31, 2026. Seven of the ten also carried a second notice — they had to move to Google Play Billing Library 8. I had a weekend, a to-do list of ten projects built at different times with different tooling, and a strong suspicion that "just bump one number" was a lie. It was.
This is the field log. Not a tidy tutorial — the actual order I did things in, the errors in the order they appeared, and where I burned time. If you're staring at the same notice, you can skip the traps I walked into.
The version chain nobody mentions up front
The requirement reads as "set targetSdk = 36." What it actually means, once you follow the errors, is closer to a small tooling upgrade per project. Here's the chain I ended up applying to every app, because each link forces the next:
targetSdk = 36requirescompileSdk = 36— you can't target an API you don't compile against.compileSdk = 36requires Android Gradle Plugin 8.9.0+. Anything older simply doesn't know Android 16 exists. My projects ranged from AGP 8.2.2 to 8.7.3; all of them had to move to 8.9.1.- AGP 8.9 requires Gradle 8.11.1+. Every wrapper that was on 8.2, 8.7, or 8.9 had to be bumped, or the build stopped with a plain version-mismatch message.
- The oldest projects were on Kotlin 1.9.x, which the newer AGP complains about. Moving those to Kotlin 2.0.21 cleared it.
- And of course the machine needs the Android 16 SDK platform and Build-Tools 36 installed, plus JDK 17 to run AGP 8.x.
The thing that made this tedious rather than hard: my ten projects used three different build styles. Some used Groovy build.gradle with a buildscript classpath. Some used Groovy with the plugins {} block. Two used Kotlin DSL build.gradle.kts, and one of those drove everything through a libs.versions.toml version catalog. The edit was the same idea each time, but where the version string lived moved around constantly. If you maintain more than a couple of apps, expect to context-switch between these formats and double-check you're editing the right file.
My rule of thumb after ten of these: do the bumps in dependency order (SDK → AGP → Gradle → Kotlin), sync once after each, and let each error message tell you the next step. Trying to change everything at once just merges five error causes into one confusing stack trace.
The billing change was smaller than I feared
Seven apps needed Play Billing Library 8. I'd blocked out hours for this and it took minutes each, because my integrations were already on the modern ProductDetails API. In v8, exactly one thing broke in each app: the queryProductDetailsAsync callback used to hand back a List<ProductDetails>; now it hands back a QueryProductDetailsResult, and you pull the list off it.
// v7
client.queryProductDetailsAsync(params) { _, list ->
list.forEach { /* ... */ }
}
// v8
client.queryProductDetailsAsync(params) { _, result ->
result.productDetailsList.forEach { /* ... */ }
}
Everything else I use — enablePendingPurchases(PendingPurchasesParams…), queryPurchasesAsync(QueryPurchasesParams…), acknowledge, consume, subscription replacement — was untouched. The one variation worth noting: apps differed in how many call sites they had. Most had one; Nuvio had two (a subscription query with a nested top-up query), and Everwise had two identical blocks. If your billing code predates ProductDetails and still calls querySkuDetailsAsync or the no-argument enablePendingPurchases(), you have real removal work first — those are gone in v8.
The error that wasn't a code error #1: a lint wall on release
This is where the weekend stopped being smooth. One app built fine in debug and then died assembling the release with a fatal lint error I hadn't seen in years:
Error: "pro_active" is translated here but not found in
default locale [ExtraTranslation]
values-en/strings.xml
2 errors, 0 warnings
Two strings existed in the English translation file but not in the default values/strings.xml. Lint treats that as a crash risk — a lookup on any locale without the string would throw — and release builds run lintVital, which is fatal by default. The strings turned out to be dead leftovers from a "Pro" tier I'd removed long ago; nothing referenced them anywhere in code or layouts. The correct fix wasn't to suppress the check with a baseline, it was to delete the two orphaned strings. I then wrote a quick script to compare every locale's keys against the default across all my apps, so this couldn't ambush me again on the next one. It's the kind of pre-existing debt that stays invisible until a stricter release build drags it into the light.
The error that wasn't a code error #2: a file lock on Windows
The one that genuinely made me question my changes turned out to have nothing to do with them:
:app:lintVitalAnalyzeRelease ...androidx.compose.runtime.lint.RuntimeIssueRegistry-....jar: The process cannot access the file because it is being used by another process
It looks like a Compose lint failure. It isn't. It's Windows telling you another process is holding a lint-cache .jar — usually a stray Gradle daemon, an antivirus scan, or a file-sync client (OneDrive/Dropbox) watching the build folder. The fix has nothing to do with Kotlin or Compose: stop the daemons with gradlew --stop, clean, and rebuild; if it persists, close the IDE and delete the app/build folder, or just reboot. I lost twenty minutes reading Compose lint documentation before I actually read the second half of the error message. Read the whole line first — the interesting part is often at the end.
Edge-to-edge: the visual surprise
Targeting recent Android enforces edge-to-edge — the system draws your content behind the status and navigation bars. On my View-based game screens, toolbars slid under the clock and buttons sat under the navigation pill. The fix is to enable it explicitly and then consume the insets as padding:
// once, ideally in a shared BaseActivity
WindowCompat.setDecorFitsSystemWindows(window, false)
ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout())
v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
The Compose screens were mostly fine — Scaffold already applies its insets once you call enableEdgeToEdge(). While I was in the themes, I also deleted android:statusBarColor and android:navigationBarColor, which are deprecated and ignored in edge-to-edge and which Play Console had been flagging as "deprecated parameters for wider screens."
The odd one out: a transitive dependency I didn't declare
One app got a "your app uses an outdated SDK version (androidx.fragment 1.1.0)" notice. I don't use Fragments in that app — it's Compose. The old version was being pulled in transitively by a Google library. The fix was to declare a modern version explicitly so it wins the resolution:
implementation("androidx.fragment:fragment:1.8.5")
That's a good reminder that Play Console reports on your resolved dependency graph, not just what you wrote in your build file. Sometimes the thing it flags is three libraries deep.
What I'd do differently
Two lessons stuck. First, the tooling upgrade is the real work, not the target number — if I'd bumped AGP/Gradle/Kotlin across all ten apps as a batch first, then flipped targetSdk, I'd have hit the version-matrix errors once instead of ten times. Second, release lint surfaces years of small debt at once. Orphaned translations, deprecated theme attributes, outdated transitive deps — none of them mattered until a stricter build cared. Budget time for that, not just for the migration itself.
Net result: ten apps compiling against API 36, seven of them on Billing 8, all with bumped version codes and ready to upload. The mandatory part — the target level and billing — was maybe 30% of the effort. The other 70% was pre-existing debt that the deadline finally forced me to pay down. Which, honestly, is not the worst outcome.
Versions, Play requirements, and deadlines change over time; treat the specifics here as a snapshot from my own migration and confirm the current numbers in the Play Console and official docs before you ship.