Twenty-one lessons have been about the debug build: installed from Android Studio onto an emulator, talking to a server on your laptop. What customers install is a different artefact — minified, optimised, signed with a key you must never lose, uploaded as a bundle Google turns into per-device APKs, and reviewed before anyone sees it. This lesson is that path, and the decisions on it that cannot be undone.
Build types
Every Android module has at least two build types, and the differences between them are declared in one block:
buildTypes {
debug {
buildConfigField(
"String",
"API_BASE_URL",
"\"${localValue("pizza.apiBaseUrl", "http://10.0.2.2:8085/")}\"",
)
applicationIdSuffix = ".debug"
}
release {
buildConfigField("String", "API_BASE_URL", "\"https://api.pizza.example.com/\"")
signingConfig = signingConfigs.findByName("upload")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
A build type can change almost anything. Here the two differ in four ways:
- Which server.
buildConfigFieldwrites a constant into the generatedBuildConfigclass per build type, so debug talks to10.0.2.2and release to the production HTTPS host, from the same source. Nothing in the app's code changes between them. - Identity.
applicationIdSuffix = ".debug"makes the debug build a different app as far as the phone is concerned, so a developer can have the store version and their work-in-progress installed side by side. - Signing, below.
- R8, below.
Build types combine with optional product flavours — free and
paid, or staging and production — into variants, each
with its own source set (src/debug/, src/release/, …) merged over
main. The debug-only network security config from the networking lesson is exactly that: a
file in src/debug/ that release builds never see.
One honest caveat about this app: the release URL is a placeholder, api.pizza.example.com.
The demo backend is not deployed anywhere, so a release build of this app installs, launches and shows
"Could not reach the server" — correctly. The release build is real and verified; the release
environment is not, and the code says so rather than pretending.
Versions
versionCode is an integer Google Play uses to order builds; every upload must be higher
than the last, and a build with a lower one cannot be installed over a higher one. versionName
is the string customers see. CI usually derives versionCode from the build number so it can
never be forgotten.
R8: shrinking, optimising, obfuscating
isMinifyEnabled = true runs R8 over the release build. It removes every class and method
nothing reachable uses, inlines and optimises what remains, and renames everything to short meaningless
names. isShrinkResources then drops unused images and strings. On this app the effect is
dramatic: the debug APK is 35 MB, the release APK 6.7 MB.
The danger is reflection. R8 decides what is "used" by following code; anything reached only by name at runtime — a class instantiated reflectively, an annotation read through reflection, a JSON field matched by name — can be removed or renamed, and the release build crashes where debug worked. The fix is a keep rule. This app's rules file is nearly empty, and says why:
# kotlinx.serialization ships its own consumer rules, and so do Retrofit, OkHttp, Hilt and Stripe.
# Nothing is needed here today. A rule added to this file should say which crash it fixes.
Modern libraries ship their own consumer rules inside their artefacts, applied
automatically. The choices made earlier in this track also helped: kotlinx.serialization generates
serialisers at compile time rather than using reflection, and the one reflective read the app does —
the @Authenticated annotation — is on a Retrofit interface method, which Retrofit's rules
already keep.
"Should be fine" is not evidence, so this app was checked the honest way: R8 was switched on for a
debug build, which can reach the local backend, and the minified app was driven on the emulator. The
menu decoded, the generic Page<Order> envelope decoded, the token was attached to
authenticated routes, and the session came back from the Keystore. A release-only crash would have
shown there. Do this before your first store upload.
Keep the mapping file R8 writes to build/outputs/mapping/, and upload it
to Play Console with each release: a crash report from an obfuscated build reads
a.b.c(Unknown Source) until it is de-obfuscated with the matching mapping.
Signing
Android will only install a signed APK, and an update only if it is signed with the same key as the installed version. Debug builds are signed automatically with a debug key Android Studio generates. Release builds need a real one, and its handling is the most consequential decision in this lesson.
With Play App Signing — the default for new apps — there are two keys. Google holds the app signing key that signs what customers install. You hold an upload key that proves an upload came from you. Lose the upload key and Google can reset it; that safety net is the main reason to use Play App Signing.
The upload key's passwords must never be in the repository. The build reads them from the
environment on CI, or from local.properties on a developer's machine:
fun secret(name: String): String? =
System.getenv(name)?.takeIf { it.isNotBlank() } ?: localProperties.getProperty(name)?.takeIf { it.isNotBlank() }
val uploadKeystore = secret("PIZZA_UPLOAD_KEYSTORE")?.let(::file)?.takeIf { it.exists() }
signingConfigs {
if (uploadKeystore != null) {
create("upload") {
storeFile = uploadKeystore
storePassword = secret("PIZZA_UPLOAD_STORE_PASSWORD")
keyAlias = secret("PIZZA_UPLOAD_KEY_ALIAS")
keyPassword = secret("PIZZA_UPLOAD_KEY_PASSWORD")
}
}
}
When no keystore is configured the release build is produced unsigned — still enough to prove
R8 works, which is what CI checks on every pull request. The signed path was verified with a throwaway
key: build, then jarsigner -verify on the bundle reported "jar verified", signed by the
throwaway certificate. Then the key was deleted.
App Bundles
The Play Store does not take an APK. It takes an Android App Bundle
(.aab) — ./gradlew bundleRelease — and generates optimised APKs for each device
configuration from it: only the screen density, CPU architecture and languages that device needs. The
customer downloads less than the universal APK you would otherwise ship.
The launcher icon is part of that: an adaptive icon with separate foreground and background layers, which each launcher masks into its own shape, and a monochrome layer for Android 13's themed icons:
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/pizza_primary" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<!-- Android 13+ "themed icons" tint this layer to the wallpaper's colours. -->
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Looking inside what you ship
Android Studio's APK Analyzer (Build → Analyze APK) opens any APK or bundle and shows
what takes the space: DEX by package, resources, native libraries. It is the quickest way to find the
dependency that doubled the download size, and to confirm that what R8 removed is what you expected it
to remove — including that SampleData and the previews are gone from release.
Baseline profiles
The largest single improvement to how a Compose app feels on first launch is a
baseline profile: a list of the classes and methods on the startup and first-scroll paths,
shipped inside the app so Android compiles them ahead of time at install instead of interpreting them on
first use. It is generated by a Macrobenchmark test that drives the real app — launch, open the menu,
scroll — on a device, through the androidx.baselineprofile Gradle plugin. Compose and the other
AndroidX libraries already ship profiles for their code, and the release build merges them in —
that is the baselineProfiles folder that appears next to the release APK. What this app lacks
is a profile for its own startup path. That is the next step for a production version, and the benchmark
that generates it doubles as the startup-time regression test.
CI
A workflow beside the app runs on every push and pull request that touches it:
- name: Test, lint and build release
run: ./gradlew testDebugUnitTest lintDebug bundleRelease
Tests, lint and a minified release build, on a cheap Linux runner, because nothing needs a device. A
second job, only on main, restores the upload keystore from repository secrets and builds
the signed bundle:
- name: Restore the upload keystore
run: echo "${{ secrets.PIZZA_UPLOAD_KEYSTORE_BASE64 }}" | base64 --decode > upload.jks
- name: Build the signed bundle
env:
PIZZA_UPLOAD_KEYSTORE: ${{ github.workspace }}/pizza/pizza-android-mobile/upload.jks
PIZZA_UPLOAD_STORE_PASSWORD: ${{ secrets.PIZZA_UPLOAD_STORE_PASSWORD }}
PIZZA_UPLOAD_KEY_ALIAS: ${{ secrets.PIZZA_UPLOAD_KEY_ALIAS }}
PIZZA_UPLOAD_KEY_PASSWORD: ${{ secrets.PIZZA_UPLOAD_KEY_PASSWORD }}
run: ./gradlew bundleRelease
Only on main, never on a pull request: a pull request from a fork must not be able to read
signing secrets. The workflow file says plainly that it has never run — the demo repository has no
remote yet — and that each command in it was run locally instead. A CI file that claims to be proven
when it is not is a small lie that costs someone a day later.
Google Play
Uploading the bundle is the start, not the end. What Play Console asks of you, roughly in order:
- A developer account, with identity verification. New personal accounts must run a closed test with a minimum number of testers over a minimum period before they can publish to production — check the current figures when you register, as Google has changed them.
- Testing tracks. Internal (up to a hundred testers, available in minutes), closed, open, then production. Use internal for every build; it is the fastest way to install a real release build on real phones.
- A target SDK requirement. New apps and updates must target a recent API level —
within about a year of the newest. This is why
targetSdkis a number you keep raising. - The Data safety form: what data the app collects, why, whether it is encrypted in transit, whether users can delete it. This app collects an email, a name, addresses and order history, sends all of it over HTTPS, and never sees card numbers — the form should say exactly that.
- A privacy policy URL, a content rating questionnaire, and store listing assets: icon, feature graphic, screenshots.
- Review. Usually hours to a few days. Rejections commonly cite a permission without a clear use, a missing policy, or a Data safety form that does not match behaviour.
Once live, ship with a staged rollout — 5% of users, then 20%, then everyone — watching the crash rate in Android vitals between steps. It is the difference between a bad build reaching a few hundred people and reaching all of them.
Before every release
- Tests, lint and a minified release build pass in CI.
- The minified build has been run against a real backend at least once since the last dependency upgrade.
versionCodewent up.- The mapping file is uploaded with the bundle.
- Internal testers have installed it from Play, not from Android Studio.
Next
One lesson left: the questions an Android interview actually asks, answered against everything in this track.