Three identifiers, three jobs
versionCode is an integer Play requires to be strictly higher than any artifact already on that applicationId (including other tracks in many setups). Reuse it and the upload is rejected. Users never see it.
versionName is the string on the store listing and in Settings. You can set it to 2.4.0-rc1. Play does not use it for uniqueness. You can ship versionName 1.0.0 forever if the integer keeps moving — ugly, legal.
applicationId is the Play listing identity. Modern AGP also has namespace for R classes. The text package attribute in source XML may no longer be what Play sees. A staging flavor with .staging suffix is a different app. Uploading it to the production listing fails for a boring reason.
What the APK Analyzer can and cannot tell you
The APK Analyzer hashes the file, lists ZIP entries, and regex-scans printable ASCII in binary AndroidManifest.xml for package=, versionName, and versionCode. When those lines appear, treat them as clues. When they are blank — common — do not invent numbers. Run aapt dump badging your.apk. The Android version checker names API levels (what “API 34” means). It does not read your package.
This expands the shorter May note versionCode mistakes we still see. Keep that post for the checklist tone; use this one when you need the three-way split.
Worked example — two reports, same afternoon
Build A (what you meant to upload last week): heuristic versionCode 24003, versionName 2.4.0, com.example.notes.
Build B (what CI produced after a merge that skipped the bump script): same versionCode, same applicationId, new git SHA. Play rejects B. The analyzer hashes differ. That is enough to stop before you burn an hour on “signing.”
Build C (staging flavor): com.example.notes.staging, versionCode 1. Perfectly valid APK. Wrong listing. The console error will not say “you used the flavor suffix.”
aapt for A should look like package: name='com.example.notes' versionCode='24003' versionName='2.4.0'. If the analyzer heuristic is blank but aapt prints that line, trust aapt. If both are blank, the file is not a usable APK.
Where Gradle actually writes these
versionCode and versionName live in the app module defaultConfig or a version.properties the CI job reads. Flavor blocks can override them. A git-count plugin that runs on a shallow clone can emit 1 on a release branch — we have seen that. Fail the job if the integer is not greater than the previous git tag.
applicationId is not namespace. After AGP 8, namespace is the R-class package. Play still keys the listing on applicationId. Changing namespace to “clean up” packages does not move the store listing. Changing applicationId creates a new app; users do not get the update.
applicationIdSuffix on a debug or staging flavor is the usual silent fork. Internal testers install .debug for weeks, then production upload “fails because versionCode is too low” — it is a different id, so Play’s production track still has last week’s integer, and someone is looking at the wrong listing.
AAB vs APK
Play wants an App Bundle for most listings. The analyzer does not unpack .aab. If someone renamed a bundle to .apk, the ZIP may open and the layout will be wrong. Produce APKs with bundletool when you need a local install check. See base APK vs splits vs AAB.
Universal APKs from bundletool still carry one versionCode. Splits share it. Do not bump the integer on one split and not the others.
Tracks consume integers
Internal testing, closed, open, and production often cannot reuse a code even if you “never shipped production.” Staged rollouts do not forgive a reused integer. Map every track in Console before you “just re-upload.” If a bad build is on 10% rollout, you still need a higher code to replace it.
CI rule we actually use
Fail the job if versionCode is not greater than the last git tag’s code. Humans forget. Screenshot the analyzer summary (or the aapt dump) in the release ticket so next month’s crash cluster has an ABI and a code attached. Store the whole-file SHA-256 next to the tag so “which APK did QA sideload” is not a Slack archaeology problem.
When to leave the browser
Gradle versionCode from a git-count plugin, Play’s list of existing codes, and flavor applicationIdSuffix live in the project. The publishing checklist is the rest of the afternoon. Job table: browser vs apktool.