Play Store Release — Android Native
Build a signed Android App Bundle (AAB) from a native Android (Java/Kotlin) project, ready for Google Play Store.
Before You Start — Assess the Project
Ask these questions BEFORE touching any files:
-
Gradle DSL? Check
app/build.gradlevsbuild.gradle.kts.kts→ useisMinifyEnabled(withisprefix).gradle→ useminifyEnabled(withoutisprefix)- NEVER mix — using the wrong property is a silent no-op (no error, no minification)
-
Existing signing config? Search for
signingConfigsin the app build file- Already exists → Verify it reads from a properties file, don't duplicate
- Missing → Add from scratch
-
Existing keystore? Ask the user before generating a new one
- Already has
.jks→ Reuse it, skip Step 1 - First release → Generate new keystore
- Already has
-
Which libraries need ProGuard rules? Check
build.gradledependencies block:- Uses Retrofit/OkHttp → Add network rules
- Uses Gson → Add serialization rules (or better: suggest migrating to kotlinx.serialization)
- Uses kotlinx.serialization → Add
@Serializablekeep rules - Uses Room/Hilt/Dagger → No custom rules needed (compile-time code generation)
- Only Jetpack Compose → No custom rules needed (Compose compiler handles it)
-
AGP version? Check
libs.versions.tomlor rootbuild.gradle- AGP 8+ → Requires Java 17. Verify
JAVA_HOMEororg.gradle.java.home - AGP 7.x → Java 11 sufficient
- AGP 8+ → Requires Java 17. Verify
-
Flavors? Search for
productFlavorsin the build file- Has flavors → Each flavor needs its own or a shared signing config; build command changes to
bundleProdReleaseetc. - No flavors → Single release config
- Has flavors → Each flavor needs its own or a shared signing config; build command changes to
Workflow
| Step | Action | Key file |
|---|---|---|
| 1 | Generate upload keystore | upload-keystore.jks |
| 2 | Create credentials file | keystore.properties |
| 3 | Configure signing in Gradle | app/build.gradle.kts |
| 4 | Configure ProGuard / R8 (by dependency) | app/proguard-rules.pro |
| 5 | Build release AAB | CLI |
| 6 | Verify output | CLI + checklist |
Step 1 — Generate Upload Keystore
keytool -genkeypair \
-alias upload \
-keyalg RSA -keysize 2048 \
-validity 10000 \
-storetype PKCS12 \
-keystore upload-keystore.jks
Critical details:
-validity 10000= ~27 years. Google requires validity beyond Oct 22 2033.-storetype PKCS12— avoids JKS migration warnings. But with PKCS12, store password and key password must be identical.keytoolsilently uses the store password for the key. Different passwords → signing fails later with misleading "Cannot recover key" error.- Store the
.jksoutside the project. Recommended:~/.android/keystores/or a secrets manager.
Step 2 — Create Credentials File
Create keystore.properties in the project root (must NOT be committed):
storePassword=<password>
keyPassword=<same-password-as-store>
keyAlias=upload
storeFile=<absolute-or-relative-path-to-upload-keystore.jks>
Add to .gitignore:
keystore.properties
*.jks
*.keystore
local.properties
Step 3 — Configure Signing in Gradle
Claude knows Gradle signing config syntax. These are the traps:
isMinifyEnabledvsminifyEnabled: KTS requires theisprefix. Groovy does NOT. Wrong prefix = silent no-op — build succeeds, APK is unminified, 3x larger, and exposes source code. No error, no warning. This is the #1 Android release mistake.isShrinkResourcesrequiresisMinifyEnabled: Resource shrinking without code minification silently does nothing. Always set both together.signingConfigsbeforebuildTypes: Gradle evaluates blocks in declaration order. Referencing a signingConfig before it's declared → build error.rootProject.file()vsproject.file():keystore.propertieslives in project root.rootProject= project root.project=app/module. Wrong root = file not found silently, null properties at build time.
Kotlin DSL (app/build.gradle.kts)
import java.util.Properties
import java.io.FileInputStream
val keystoreProperties = Properties().apply {
val file = rootProject.file("keystore.properties")
if (file.exists()) load(FileInputStream(file))
}
android {
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
For Groovy DSL: same structure but use def keystoreProperties = new Properties(), untyped property access keystoreProperties['keyAlias'], minifyEnabled true (no is prefix), shrinkResources true, and signingConfig signingConfigs.release (no =).
Step 4 — ProGuard / R8 (Add Rules By Dependency)
R8 is NOT enabled by default in native Android — you must explicitly set isMinifyEnabled = true. Then add rules ONLY for libraries the project actually uses:
| Dependency | Needs rules? | Why |
|---|---|---|
| Retrofit | YES | Uses reflection for interface proxies |
| OkHttp | YES | Uses reflection for platform detection |
| Gson | YES | Deserializes via reflection on field names |
| kotlinx.serialization | YES | Compiler plugin generates serializers that R8 strips |
| Coroutines | YES | Internal classes loaded via ServiceLoader |
| Room | NO | Annotation processor, compile-time only |
| Hilt/Dagger | NO | Compile-time code generation |
| Jetpack Compose | NO | Compose compiler handles it |
| Coil/Glide | MAYBE | Only if using custom transformations |
When R8 reports Missing class: — copy rules from the error output verbatim.
When a crash occurs only in release build — the stripped class is in the stack trace.
Common rules (add only what applies):
# Kotlin (always needed with minification)
-keep class kotlin.Metadata { *; }
-dontwarn kotlin.**
# Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** { volatile <fields>; }
# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
-keep,includedescriptorclasses class **$$serializer { *; }
-keepclassmembers class * { @kotlinx.serialization.Serializable *; }
# Retrofit + OkHttp
-keepattributes Signature, Exceptions
-keep class retrofit2.** { *; }
-keepclasseswithmembers class * { @retrofit2.http.* <methods>; }
-dontwarn okhttp3.**
-dontwarn okio.**
# Gson (consider migrating to kotlinx.serialization)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
Step 5 — Build Release AAB
./gradlew bundleRelease
Useful flags:
--stacktrace— full stack trace on R8/signing errors.-Pandroid.injected.signing.store.file=...— override signing via CLI (CI use)../gradlew clean bundleRelease— clean before building after Gradle config changes.
Output: app/build/outputs/bundle/release/app-release.aab
Step 6 — Verify Before Upload
# Verify signing — confirm alias is "upload", NOT "androiddebugkey"
keytool -printcert -jarfile app/build/outputs/bundle/release/app-release.aab
# Verify version (requires bundletool)
bundletool dump manifest --bundle=app/build/outputs/bundle/release/app-release.aab \
| grep -E "versionCode|versionName"
Checklist:
- AAB signed with upload key (not debug) — debug key = #1 rejection reason
-
versionCodehigher than the previous upload -
keystore.propertiesand*.jksin.gitignore -
isMinifyEnabled = trueandisShrinkResources = trueboth set - ProGuard rules added only for reflection-heavy dependencies
NEVER Do
-
NEVER use the wrong minify property name — KTS:
isMinifyEnabled. Groovy:minifyEnabled. The wrong one compiles fine but is a silent no-op — your release build is unminified, 3x larger, and exposes full source code. No error, no warning. Test by checking AAB size. -
NEVER enable
isShrinkResourceswithoutisMinifyEnabled— Resource shrinking depends on code minification to determine which resources are referenced. Without minify, shrink silently does nothing. Always set both. -
NEVER set different store/key passwords with PKCS12 —
keytoolsilently uses store password for key. Different passwords → signing fails with "Cannot recover key" (misleading — it's a password mismatch, not a corrupt key). -
NEVER dump all ProGuard rules blindly — Add rules ONLY for libraries the project uses. Unnecessary
-keeprules defeat the purpose of R8 by preventing dead code removal and increasing APK size. -
NEVER skip testing the signed AAB — R8 stripping is invisible until runtime. Install the release build on a real device and test all screens, especially those using serialization, reflection, or native code. Crashes that only appear in release builds are always R8-related.
-
NEVER ignore lint errors by default —
abortOnError = falseinlint {}block is a common workaround, but it hides real issues. Fix lint errors first. Only suppress specific lint IDs you've reviewed:disable += "SomeSpecificCheck".
Common Errors
| Error | Cause | Fix |
|---|---|---|
Missing class: ... during R8 | R8 strips classes used via reflection | Add -keep rules from error output |
NoSuchMethodError at runtime | R8 removed constructor needed by serialization | Add -keep for affected data class |
| Serialization crash only in release | @Serializable classes stripped by R8 | Add kotlinx-serialization ProGuard rules |
Lint found errors blocking build | abortOnError is true by default | Fix lint issues or suppress specific IDs |
| "debug certificate" rejection | Built without release signing config | Verify signingConfig in buildTypes.release |
| Build succeeds but APK is huge | isMinifyEnabled silently not applied | Check property name matches DSL (is/no-is) |
Gotchas
-
Java 17 is required for AGP 8+ — If build fails with "Unsupported class file major version", set
org.gradle.java.homeingradle.propertiesto a JDK 17+ path. Or setJAVA_HOMEin your shell environment. -
local.propertiesmust not be committed — Contains machine-specific SDK path. Always in.gitignore. If it's committed and another dev has a different SDK path, builds fail with confusing "SDK not found" errors. -
App Signing by Google Play — Google re-signs your app with their app signing key. The keystore you generate is the upload key only. If you lose it, request a reset through Play Console (takes days, requires identity verification).
-
Flavor-aware build commands — With
productFlavors,bundleReleasebuilds ALL flavors. To build a specific one:./gradlew bundleProdRelease(capitalize flavor name). The output path also changes:app/build/outputs/bundle/prodRelease/.