Android – Building and Shipping

October 7, 20268 min readUpdated 10/11/2026

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. buildConfigField writes a constant into the generated BuildConfig class per build type, so debug talks to 10.0.2.2 and 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 targetSdk is 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

  1. Tests, lint and a minified release build pass in CI.
  2. The minified build has been run against a real backend at least once since the last dependency upgrade.
  3. versionCode went up.
  4. The mapping file is uploaded with the bundle.
  5. 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.