Build Variants & Signing Explained
BUILD & TOOLING › Gradle
Every Android module ships with two default **build types**, debug and release, and you can add more. debug is debuggable, signed automatically with a throwaway local keystore, and skips code shrinking. release is not debuggable by default and is where you'd turn on shrinking (isMinifyEnabled = true) and wire a real signing config, Gradle builds it unsigned until you do.
android {
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
Build types answer *how* the app is built (debuggable? shrunk? signed how?), which is a different axis from product flavors, which answer *what version* of the app is built.
**Product flavors** represent different versions of your app, like free or paid or dev or prod, and every flavor must belong to a named **flavor dimension** via dimension = "...". The total number of build variants is the cross product of all flavors (multiplied across every dimension) times all build types.
flavorDimensions += listOf("tier", "env")
productFlavors {
create("free") { dimension = "tier" }
create("paid") { dimension = "tier" }
create("dev") { dimension = "env" }
create("prod") { dimension = "env" }
}
// 2 tier flavors × 2 env flavors × 2 build types = 8 variants
Gradle names the resulting assemble tasks by concatenating flavors (in dimension declaration order) then the build type, capitalized: assembleFreeDevDebug, assemblePaidProdRelease, and so on.
You inject per-variant values two ways. **buildConfigField** adds a typed constant to the generated BuildConfig class, readable straight from Kotlin. Since AGP 8.0, BuildConfig generation is opt-in, you must set buildFeatures { buildConfig = true } or the class won't exist at all. **resValue** instead generates a resource value, accessible through R, useful when something like a manifest placeholder or an XML layout needs it as a resource rather than a Kotlin constant.
buildTypes {
debug {
buildConfigField("String", "API_URL", "\"https://dev.example.com\"") // note: quotes inside the value
}
}
Both can be set per build type or per flavor, and the more specific one (variant-level, then build type, then flavor) wins if they collide.
Each variant can have its own source set folder, and Gradle merges them by priority when duplicate resources or code exist. For a fullRelease variant (flavor full, build type release), the priority highest to lowest is: src/fullRelease (variant-specific) > src/release (build type) > src/full (flavor) > src/main (always included, lowest priority).
src/fullRelease/ ← wins if there's a conflict
src/release/
src/full/
src/main/ ← baseline, always merged in
Duplicate Kotlin or Java classes for the *same* variant are a hard build error, Gradle can't pick a winner between two definitions of the same class at the same priority level.
A signingConfig tells Gradle which keystore, alias, and passwords to sign a build type with. The **debug** build type auto-attaches a local, auto-generated keystore at $HOME/.android/debug.keystore with well-known credentials, fine for local testing, but Google Play and most other stores reject anything signed with it. For release, you declare and wire your own:
android {
signingConfigs {
create("release") {
storeFile = file("release.jks")
storePassword = System.getenv("STORE_PASS")
keyAlias = "my-key"
keyPassword = System.getenv("KEY_PASS")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
Skip that wiring and assembleRelease still succeeds, but it emits an unsigned app-release-unsigned.apk that can't be installed or uploaded.
Under **Play App Signing**, two distinct keys are involved, and interviewers love mixing them up in the question. You sign your uploaded AAB with your own **upload key**. Google verifies that upload, then re-signs the APKs it actually distributes to devices with the **app signing key**, which it generates and stores securely in its own KMS, you never see it again.
The payoff shows up when things go wrong: if you lose your upload key, you request an **upload key reset** in the Play Console and keep shipping, since Google still holds the app signing key that devices actually trust. Without Play App Signing, losing your one and only signing key means you can never publish an update to that app again.
Two small DSL knobs solve a common annoyance: installing debug and release side by side on one device. applicationIdSuffix (for example .debug) appends to the applicationId, so the two builds get genuinely different package names and can coexist; versionNameSuffix only changes the *displayed* version string and has no effect on coexistence.
buildTypes {
debug {
applicationIdSuffix = ".debug" // com.example.app → com.example.app.debug
versionNameSuffix = "-debug"
}
}
Putting it all together: build types answer *how* (debuggable, shrunk, signed), flavors answer *what version*, their cross product is the variant matrix, source sets and buildConfigField or resValue let each variant customize itself, and signing configs, backed by Play App Signing's two-key model, get the result safely onto devices.