Building, Signing and Preparing a Release

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Publishing an Android app means turning a tree of Kotlin sources and resources into one signed artifact that Google Play and, ultimately, a user’s device will accept. That path has three distinct stages — building (compiling and, for release, shrinking the code with R8), packaging (choosing between an installable APK and the Play-only Android App Bundle format), and signing (attaching a cryptographic identity to the artifact that Android verifies on every install and update) — and each stage has its own failure modes if skipped or misconfigured. This page covers all three for TrailMate, the running example introduced in Getting Started with Android, building on the android {} DSL and build variants from Project Structure, Gradle and Build Variants. It stops short of the Play Console itself — creating a listing, staged rollouts, testing tracks and the Play App Signing enrollment flow are covered in Publishing on Google Play — and stops short of library publishing, covered in Building and Publishing Android Libraries to Maven Central. The baseline throughout is AGP 9.4.1 / Gradle 9.6 / JDK 17, as established on the project structure page.

Build Types: Debug vs. Release

Every Android module ships with two build types by default, debug and release, each producing a separate variant of the app with its own source set (src/debug/, src/release/) and its own BuildConfig constants. They differ in what they optimize for:

debug release

isDebuggable

true — debugger can attach, Log.d/Log.v calls are cheap to leave in

false — not attachable in production, stripped of debug symbols by default

isMinifyEnabled

false (fast, incremental builds)

true — R8 shrinks, obfuscates and optimizes

isShrinkResources

false

true — unused resources removed alongside unused code

Signing

the auto-generated debug keystore (~/.android/debug.keystore)

a release keystore you generate and control (see below)

applicationIdSuffix

typically .debug, so both variants can be installed side by side

none — this is the identity Play and users see

// app/build.gradle.kts
android {
    buildTypes {
        debug {
            applicationIdSuffix = ".debug"
            isDebuggable = true
        }
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            signingConfig = signingConfigs.getByName("release")
        }
    }
}

getDefaultProguardFile("proguard-android-optimize.txt") pulls in AGP’s own baseline rules (keep Activity/Service/ContentProvider subclasses reachable from the manifest, keep Parcelable CREATOR fields, and similar Android-framework requirements every app needs); proguard-rules.pro is where TrailMate adds its own, project-specific rules. See Android Developers — Configure Build Variants.

R8: Shrinking, Obfuscating and Optimizing

R8 is the single tool AGP uses for two historically separate jobs: it is D8’s superset, so it still converts JVM bytecode to DEX, but for a release build with isMinifyEnabled = true it also:

  • Shrinks — performs whole-program reachability analysis from the app’s entry points (the manifest’s components, plus anything a keep rule pins) and removes classes, methods and fields nothing reaches.

  • Obfuscates — renames surviving classes/methods/fields to short, meaningless names (a, b, a.a, …​), shrinking the DEX further and making reverse-engineering harder.

  • Optimizes — inlines, removes dead branches, merges classes where safe, and otherwise rewrites the bytecode graph for size and speed beyond what a naive compiler would do.

This is not the legacy standalone proguard.jar tool; AGP replaced ProGuard with R8 as the default shrinker years ago, but it still reads the same proguard-rules.pro syntax (-keep, -keepclassmembers, -dontwarn, …​) for backward compatibility, which is why the file and the rule syntax keep the ProGuard name.

Keep Rules

Reachability analysis is static: it cannot see through reflection, JNI, Retrofit’s dynamic proxy generation, or Gson/Moshi/kotlinx.serialization reading field names at runtime. Anything reached only that way must be pinned with an explicit -keep rule, or R8 will strip or rename it and the app will crash or silently misbehave only in the release build — a class of bug that never reproduces in debug and is easy to ship by accident.

// app/proguard-rules.pro

# Keep DTOs whose field names Retrofit/kotlinx.serialization read via reflection.
-keep class com.example.trailmate.core.network.dto.** { *; }

# Keep Room-generated implementations (Room's own consumer rules already cover most of this,
# but explicit entities are kept for clarity when annotation processing changes shape).
-keep class com.example.trailmate.core.database.** { *; }

# Kotlin coroutines/Compose internals commonly warn under R8 without affecting correctness.
-dontwarn kotlinx.coroutines.**

# Keep the app's own Parcelable CREATOR fields (belt-and-suspenders on top of AGP's default rule).
-keepclassmembers class com.example.trailmate.** implements android.os.Parcelable {
    public static final ** CREATOR;
}

Most libraries ship their own consumer ProGuard rules bundled in their AAR (Retrofit, Room, Hilt, Moshi and Gson all do), so an app rarely has to hand-write rules for a well-maintained third-party dependency — only for its own reflection-based code and DTOs. Over-broad -keep class com.example.trailmate.** { *; } rules defeat shrinking almost entirely and are a common mistake; keep rules should be as narrow as the reflective access actually requires. See Android Developers — Shrink, Obfuscate and Optimize Your App and Android Developers — Shrink, Obfuscate and Optimize Your App (keep rules).

Mapping Files

Because R8 renames classes and methods, a release build’s stack traces arrive obfuscated (Exception in a.b.c: at a.b.c.a(Unknown Source)), useless without the corresponding mapping file. AGP writes one per release build to app/build/outputs/mapping/release/mapping.txt, a text table of original.name → obfuscated.name for every renamed symbol. That file must never ship inside the app; it is instead retained and, when the app is distributed through Google Play, uploaded to the Play Console (App bundle explorer → Downloads, or automatically via the Play publishing Gradle plugin) so Play Console’s own crash reporting (and Firebase Crashlytics, via its own upload step) can de-obfuscate stack traces back to real symbol names. Losing the mapping file for a shipped versionCode means every crash report from that release stays permanently unreadable, so archiving it (per release, keyed by versionCode) is worth automating in CI rather than treating as optional. See Android Developers — Decode an Obfuscated Stack Trace.

Resource Shrinking

isShrinkResources = true (only effective alongside isMinifyEnabled = true, since it relies on R8 having already found which code — and therefore which resource references — survives) removes resources nothing in the surviving code references: unused drawables, layouts kept only for a since-deleted screen, unused string sets in alternate locales, and so on. It cannot detect resources referenced only by name at runtime (for example resources.getIdentifier("ic_trail_$difficulty", …​)); those need an explicit keep in a res/raw/keep.xml resource:

<?xml version="1.0" encoding="utf-8"?>
<!-- app/src/main/res/raw/keep.xml -->
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@drawable/ic_trail_easy,@drawable/ic_trail_moderate,@drawable/ic_trail_hard" />

Packaging: APK vs. Android App Bundle

An APK (.apk) is a single, complete, installable archive: every resource for every density and every native library for every ABI the app supports, in one file. An Android App Bundle (.aab) is a publishing format, not an installable one: it packages the same content split by dimension (density, ABI, language) and defers assembling a device-specific APK set to Google Play at install time (dynamic delivery), so a given device only downloads the density, ABI and language slices it actually needs. Google Play has required the AAB format for new apps since August 2021, and it is the default artifact Android Studio’s release build config now produces.

# generate a release AAB from the command line
./gradlew :app:bundleRelease
# generate a release APK from the command line (still useful for direct-install / non-Play distribution)
./gradlew :app:assembleRelease

bundletool: Extracting and Testing an AAB Locally

Because an .aab is not directly installable, testing what Play would actually generate for a specific device requires Google’s bundletool command-line tool.

# build a full APK set (one split-APK set per supported device configuration) from the AAB
java -jar bundletool.jar build-apks \
    --bundle=app/build/outputs/bundle/release/app-release.aab \
    --output=trailmate.apks \
    --ks=release.keystore \
    --ks-key-alias=trailmate \
    --ks-pass=file:keystore_password.txt

# install the right split-APK set for the currently connected device/emulator
java -jar bundletool.jar install-apks --apks=trailmate.apks

build-apks --local-testing produces a set installable without Play at all, which is how a release AAB is validated end to end (splits resolve correctly, the app launches) before it is ever uploaded. See Android Developers — bundletool and Android Developers — About Android App Bundles.

versionCode and versionName Strategy

Every build variant carries two version identifiers, read by Android at install time and shown to users respectively:

  • versionCode — a positive integer, invisible to users, that Android and Play use to decide whether an artifact is an upgrade. Play Console rejects an upload whose versionCode is not strictly greater than every versionCode already live for that app.

  • versionName — an arbitrary human-readable string ("2.4.1"), shown in Play Store listings and device settings; it has no ordering semantics Android itself enforces.

Two common versionCode strategies:

Strategy Scheme Trade-off

Monotonic counter

increment by 1 (or a fixed step, e.g. +10 per platform, to leave room for platform-specific bundles) on every release

simplest; carries no information about when a build was made; a single shared counter for versionCode is easy to keep across multiple flavors/ABIs if each needs its own code

Date-based

e.g. yyMMddHH or a similar monotonically increasing date/time encoding

self-documenting (the code tells you roughly when it was cut); still monotonic as long as releases are cut in chronological order; risks overflowing the encoding scheme’s assumptions if two releases ship the same hour

Whichever scheme is chosen, the single-source-of-truth pattern keeps both identifiers out of AndroidManifest.xml and out of scattered literals, computing them once in Gradle and reading them from a single place (a version catalog entry, a version.properties file, or CI’s own build-number output) rather than hand-editing build.gradle.kts per release:

// app/build.gradle.kts
val versionProps = java.util.Properties().apply {
    load(rootProject.file("version.properties").inputStream())
}

android {
    defaultConfig {
        versionCode = versionProps.getProperty("VERSION_CODE").toInt()
        versionName = versionProps.getProperty("VERSION_NAME")
    }
}

Signing

An unsigned or debug-signed artifact cannot be published: Android verifies a signing certificate on every install and on every update (an update whose signature does not match the currently installed app’s is rejected outright), so a release keystore is the app’s long-term identity, not just a build requirement.

Creating a Keystore

Two equivalent ways to generate a release keystore:

  • keytool (bundled with the JDK), from the command line:

    keytool -genkeypair -v \
        -keystore release.keystore \
        -alias trailmate \
        -keyalg RSA -keysize 2048 -validity 10000

    keytool then prompts interactively for the keystore password, the key password and the certificate’s distinguished name fields; none of those values belong in a script or a committed file.

  • Android Studio’s "Generate Signed Bundle / APK" wizard (Build → Generate Signed Bundle / APK → Create new…​), which drives the same keytool logic through a form and can also load an existing keystore for day-to-day signed builds without touching the command line.

-validity 10000 (roughly 27 years) matters because Play requires an app’s signing certificate to remain valid past a very long horizon; a certificate that expires mid-lifecycle of an app that is still being updated is a real, avoidable failure mode.

Wiring signingConfigs Without Committing Secrets

The keystore file itself, its passwords, and the key alias must never be committed to source control. The standard pattern reads them from a local, gitignored properties file and falls back to CI environment variables for any value the file doesn’t supply, so the same build script works locally and in automation:

// app/build.gradle.kts
import java.util.Properties

val keystoreProperties = Properties().apply {
    val propsFile = rootProject.file("keystore.properties")
    if (propsFile.exists()) {
        load(propsFile.inputStream())
    }
}

android {
    signingConfigs {
        create("release") {
            storeFile = (keystoreProperties.getProperty("storeFile") ?: System.getenv("KEYSTORE_PATH"))
                ?.let { rootProject.file(it) }
            storePassword = keystoreProperties.getProperty("storePassword")
                ?: System.getenv("KEYSTORE_PASSWORD")
            keyAlias = keystoreProperties.getProperty("keyAlias")
                ?: System.getenv("KEY_ALIAS")
            keyPassword = keystoreProperties.getProperty("keyPassword")
                ?: System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
        }
    }
}
# keystore.properties -- lives at the repo root, listed in .gitignore, never committed
storeFile=../release.keystore
storePassword=<local-only, not committed>
keyAlias=trailmate
keyPassword=<local-only, not committed>
# .gitignore
keystore.properties
*.keystore
*.jks

CI builds typically skip keystore.properties entirely and rely only on the System.getenv(…​) fallbacks: KEYSTORE_PATH points at the keystore file, which is decoded from a secret at build time rather than checked into the repository in any form, and KEYSTORE_PASSWORD, KEY_ALIAS and KEY_PASSWORD come from the CI provider’s own encrypted secrets store. These are the same four names the release workflow in CI/CD with GitHub Actions exports, and the two must match exactly — a signing config left without a storeFile or password makes bundleRelease fail. See Android Developers — Sign Your App.

APK Signature Schemes

Android has accumulated four signing schemes, each addressing a limitation of the one before it, and a modern build applies several of them simultaneously (AGP’s default signing config since API 30-targeting builds enables v1 through v3, with v4 available where the app opts in):

Scheme What it covers

v1 (JAR signing)

the original scheme, per-entry signatures inside the ZIP/JAR structure; kept only for compatibility with API levels below 24, since it does not protect large parts of the archive’s own structure

v2 (APK Signature Scheme)

introduced in Android 7.0 (API 24); signs the whole APK as a contiguous block (not per-file), closing gaps v1 left open and making installation faster since the whole file is verified in one pass

v3

introduced in Android 9.0 (API 28); adds key rotation support, so an app’s signing key can be rotated over time while preserving a verifiable link back to the original key

v4

introduced in Android 11 (API 30); a lightweight signature built alongside an APK-content hash tree, required for incremental/streamed installs (e.g. via ADB’s incremental install or Play’s own delivery); always accompanied by a v2 or v3 signature over the same content, never used alone

Inspecting a Signed Artifact with apksigner

apksigner, also bundled with the Android SDK build-tools, verifies which schemes an artifact actually carries and prints its signing certificate details — the standard way to confirm a CI-produced release artifact was signed as expected before it is uploaded:

apksigner verify --verbose --print-certs app-release.apk

which reports, per scheme, whether verification succeeded (Verified using v1/v2/v3/v4 scheme: true/false) and the signer certificate’s subject, SHA-1 and SHA-256 fingerprints. See Android Developers — apksigner and Android Open Source Project — APK Signature Scheme.

The Debug Keystore’s Fingerprint

The auto-generated debug keystore (~/.android/debug.keystore, alias androiddebugkey, password android — a fixed, publicly known default used only for local development, never for anything reachable in production) has its own SHA-1/SHA-256 fingerprint, which many Google APIs (Maps SDK, Google Sign-In, Firebase) require registering per-environment (debug vs. release) in their respective consoles:

keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

The command prints both fingerprints; the release keystore’s fingerprint is obtained the same way, substituting its own path, alias and password. A common mistake is registering only the release fingerprint with a third-party API console and then finding the corresponding feature broken in every debug build, or vice versa — both fingerprints normally need registering, side by side, wherever an API key is scoped by certificate.

Upload Key vs. App Signing Key

Once an app is enrolled in Play App Signing (mandatory for any app published as an AAB), the keystore generated above is technically the upload key, not the key that ever reaches a user’s device: Google Play verifies the upload key’s signature on every upload, then strips it and re-signs the artifact with a separate app signing key that only Google holds.

Diagram of the Play App Signing chain: the developer signs the AAB with the upload key and uploads it to Google Play; Google Play verifies the upload key, then re-signs the artifact with the app signing key that only Google’s Play App Signing service holds; Play generates device-specific signed APKs from that re-signed artifact and installs them, with the signature verified, on the user’s device. A footer note states that the upload key never leaves the developer’s control, only Google ever holds the app signing key, and that a lost or compromised upload key can be reset by Google without changing the app’s identity on device.

This separation is what makes upload-key loss recoverable (Google can reset a lost or compromised upload key without changing the app’s identity on device, since the app signing key — the one users' devices actually trust — never left Google’s custody) and is covered in full, including enrollment and key-reset procedures, in Publishing on Google Play. See Google Play Help — About App Signing.

The 16 KB Page-Size Compliance Check

Android has historically assumed a 4 KB memory page size; newer hardware supports larger pages (16 KB), which reduces app launch time and memory pressure but requires an app’s native code to be built and aligned for it. Any app that ships native libraries (.so files, whether its own JNI code or a third-party native dependency) and targets API level 35 or higher must support 16 KB pages: as of February 1, 2027, app updates without 16 KB support will no longer be accepted for apps targeting API 35+. Meeting the requirement needs AGP 8.5.1 or newer and NDK r28 or newer (both are satisfied by this section’s AGP 9.4.1 baseline); apps with no native code at all are unaffected, since the requirement is specifically about native library page alignment. See Android Developers — Support 16 KB Page Sizes.

Release Checklist

  • versionCode incremented past every versionCode already live for this application, versionName updated to match the release notes.

  • isMinifyEnabled and isShrinkResources both true on the release build type; proguard-rules.pro reviewed for any new reflection-based code (new DTOs, new libraries) that needs a keep rule.

  • Release build produced and smoke-tested end to end: ./gradlew :app:bundleRelease, then verified locally with bundletool build-apks --local-testing before upload.

  • mapping.txt for this exact versionCode archived (and, if using Play’s own crash reporting or Firebase Crashlytics, uploaded) before the artifact is discarded from the build output directory.

  • apksigner verify --print-certs run against the built artifact, confirming the expected signing certificate and signature schemes.

  • Release keystore, keystore.properties and any CI secret values confirmed absent from the git history for this branch (never just the working tree).

  • If the app ships native code and targets API 35+: confirmed 16 KB page-size support (AGP 8.5.1+/NDK r28+) ahead of the February 1, 2027 enforcement date.

  • Debug and release SHA-1/SHA-256 fingerprints re-confirmed against every certificate-scoped API key (Maps, Google Sign-In, Firebase) still in use.

  • Release notes drafted for the target Play Console track, and the upload key/app signing key relationship (see Publishing on Google Play) understood by whoever holds the keystore, before it is needed under time pressure.