Project Structure, Gradle and Build Variants

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.

Every Android app is a Gradle project, and Gradle’s job is to turn a tree of Kotlin/XML source files and declared dependencies into one or more signed .apk/.aab artifacts. This page walks through that project as it exists on disk, the files that configure it, and the build system that compiles it, targeting the section’s baseline: Android Gradle Plugin (AGP) 9.4.1 on Gradle 9.6 and JDK 17, with AGP’s built-in Kotlin compiler (Kotlin 2.4.20, no separate kotlin-android plugin), KSP 2.3.12 for annotation processing, and the Compose compiler as its own Gradle plugin. The running example, as introduced in Getting Started with Android, is TrailMate, a hiking-trail companion app. The Kotlin language itself — syntax, coroutines, the type system — is covered in the Kotlin Reference and is not repeated here; Kotlin: Build and Tooling covers the Kotlin compiler and Gradle plugin from the language side, while this page covers the Android-specific android {} DSL, build variants and packaging.

Project and Module Layout

A freshly generated Android Studio project looks like this once TrailMate grows past a single module:

TrailMate/
├── build-logic/                       # convention plugins (see below)
│   ├── settings.gradle.kts
│   └── convention/
│       ├── build.gradle.kts
│       └── src/main/kotlin/
│           ├── trailmate.android.application.gradle.kts
│           ├── trailmate.android.library.gradle.kts
│           └── trailmate.android.library.compose.gradle.kts
├── gradle/
│   ├── libs.versions.toml             # the version catalog
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── app/                                # the :app module, produces the .apk/.aab
│   ├── build.gradle.kts
│   ├── proguard-rules.pro
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── kotlin/com/example/trailmate/
│       │   └── res/
│       ├── debug/                     # debug-only source set
│       ├── release/                   # release-only source set
│       ├── test/                      # local JVM unit tests
│       └── androidTest/               # instrumented tests
├── core/
│   ├── model/build.gradle.kts         # :core:model -- plain Kotlin, no Android deps
│   ├── data/build.gradle.kts          # :core:data
│   ├── database/build.gradle.kts      # :core:database -- Room
│   └── network/build.gradle.kts       # :core:network -- Retrofit/Ktor
├── feature/
│   ├── trails/build.gradle.kts        # :feature:trails
│   └── settings/build.gradle.kts      # :feature:settings
├── build.gradle.kts                    # root build script (usually just plugin declarations)
├── settings.gradle.kts
├── gradle.properties
├── gradlew / gradlew.bat
└── local.properties                    # SDK location, never committed

Each :core:* and :feature:* directory is its own Gradle module (an Android library or a plain Kotlin/JVM module), with its own build.gradle.kts and src/main/AndroidManifest.xml if it needs Android APIs. The app architecture page explains why TrailMate is split this way (separation of concerns, faster incremental builds, enforced api/implementation boundaries); this page covers how each module is configured to build at all. See Android Developers — Overview of Android Studio Projects.

settings.gradle.kts

settings.gradle.kts is read before any other build script. It declares which modules exist and, in modern Gradle, centralizes two things that used to be scattered across every module: where plugins come from, and where dependencies come from.

// settings.gradle.kts
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "TrailMate"

include(":app")
include(":core:model", ":core:data", ":core:database", ":core:network")
include(":feature:trails", ":feature:settings")

pluginManagement.repositories controls where Gradle resolves plugins (AGP, the Kotlin plugin, KSP, Room, Hilt). dependencyResolutionManagement.repositories controls where it resolves ordinary dependencies (library artifacts), and FAIL_ON_PROJECT_REPOS forbids any module’s own build.gradle.kts from declaring a repositories {} block, so every module resolves from the same two repositories declared once, centrally. This is the standard shape Android Studio’s project wizard has generated since AGP 8. See Android Developers — Gradle Build Overview and Gradle — Centralizing Repository Declarations.

build-logic/settings.gradle.kts is a second, separate settings file: TrailMate treats build-logic as an included build (includeBuild("build-logic") in the root settings.gradle.kts) so its convention plugins can be applied by name from every module, as covered later on this page.

The Version Catalog: gradle/libs.versions.toml

A version catalog is a single TOML file that names every dependency and plugin version once, generating typed accessors (libs.androidx.core.ktx, libs.plugins.android.application, …) that autocomplete in every build.gradle.kts. It replaces hand-written ext {} blocks and string-typed dependency coordinates. A representative (trimmed) TrailMate catalog:

# gradle/libs.versions.toml
[versions]
agp = "9.4.1"
kotlin = "2.4.20"
ksp = "2.3.12"
composeBom = "2026.09.00"
coreKtx = "1.17.0"
lifecycle = "2.11.0"
activityCompose = "1.13.0"
navigationCompose = "2.10.2"
room = "2.8.5"
retrofit = "3.0.0"
okhttp = "5.5.0"
hilt = "2.60.1"
hiltNavigationCompose = "1.4.0"
coil = "3.6.3"
kotlinxSerializationJson = "1.11.0"
coroutines = "1.11.0"
junit = "4.13.2"
mockk = "1.14.11"
turbine = "1.2.1"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

[bundles]
compose = ["compose-ui", "compose-ui-tooling-preview", "compose-material3"]

Note that AGP and Kotlin’s own version is declared as a plugin version ([plugins]), applied without a version in every module (alias(libs.plugins.android.application) apply false at the root, then plain alias(libs.plugins.android.application) per module) — there is no kotlin-android entry at all, because AGP 9’s built-in Kotlin support means the Kotlin compiler ships as part of the Android Gradle Plugin itself rather than as a separate plugin applied per module. See Android Developers — Migrate your build to Version Catalogs and Gradle — Version Catalogs.

The Root build.gradle.kts

The root build script in a catalog-based, convention-plugin project is deliberately thin — it only declares which plugins are available to sub-projects, without applying them anywhere:

// build.gradle.kts (root)
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.android.library) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.kotlin.serialization) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.hilt.android) apply false
}

apply false resolves and downloads the plugin’s classpath once, at the root, without activating it — each module then applies only the plugins it actually needs. This avoids every module re-declaring buildscript { dependencies { classpath(…​) } }, the pre-Gradle-7 way of doing the same thing. See Android Developers — Migrate your build to the Kotlin DSL.

The :app Module’s build.gradle.kts

This is where the android {} DSL lives. A representative TrailMate app/build.gradle.kts under AGP 9.4, built-in Kotlin, the Compose compiler plugin and KSP:

// app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt.android)
}

android {
    namespace = "com.example.trailmate"
    compileSdk = 37

    defaultConfig {
        applicationId = "com.example.trailmate"
        minSdk = 26
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
        debug {
            applicationIdSuffix = ".debug"
            isDebuggable = true
        }
    }

    flavorDimensions += "environment"
    productFlavors {
        create("staging") {
            dimension = "environment"
            buildConfigField("String", "BASE_URL", "\"https://staging.api.example.com/v1/\"")
        }
        create("production") {
            dimension = "environment"
            buildConfigField("String", "BASE_URL", "\"https://api.example.com/v1/\"")
        }
    }

    buildFeatures {
        compose = true
        buildConfig = true
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    kotlin {
        jvmToolchain(17)
    }

    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

dependencies {
    implementation(project(":core:model"))
    implementation(project(":core:data"))
    implementation(project(":feature:trails"))
    implementation(project(":feature:settings"))

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.compose.bom))
    implementation(libs.bundles.compose)
    implementation(libs.androidx.navigation.compose)

    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    implementation(libs.hilt.navigation.compose)

    debugImplementation(libs.compose.ui.tooling)

    testImplementation(libs.junit)
    testImplementation(libs.mockk)
    testImplementation(libs.kotlinx.coroutines.test)
    androidTestImplementation(platform(libs.compose.bom))
    androidTestImplementation(libs.compose.ui.test.junit4)
}

Because AGP 9 has built-in Kotlin, there is no separate id("org.jetbrains.kotlin.android") plugin to apply — com.android.application itself understands .kt sources and exposes the kotlin { jvmToolchain(17) } block directly inside android {}. The Compose compiler is applied as its own plugin (org.jetbrains.kotlin.plugin.compose) rather than being wired implicitly, which is what lets each module pin an independent Compose compiler version if it ever needs to. See Android Developers — Migrate to AGP’s Built-in Kotlin Support, Android Developers — Kotlin Support in the Android Gradle Plugin and Android Developers — Configure Your Build Module.

The android {} DSL, Field by Field

Property Meaning

namespace

The package Kotlin/Java sources and generated R/BuildConfig classes live under (compile-time only).

applicationId

The identifier the Play Store and the device’s package manager use to identify the app; can differ from namespace and can vary per flavor/build type (applicationIdSuffix).

compileSdk / minSdk / targetSdk

Covered in Getting Started with Android; compileSdk picks the platform APIs available at compile time, minSdk/targetSdk go into the merged manifest.

buildTypes

Named build configurations (debug, release, and any custom ones) controlling minification, shrinking, signing and debuggability. Every module has debug and release by default.

productFlavors / flavorDimensions

Named variations along one or more dimensions (here, environment — staging vs production); each flavor can override applicationId, resources, buildConfigFields and its own source set.

buildFeatures

Opt-in switches for generated code paths: compose, buildConfig, viewBinding, dataBinding (legacy), aidl, renderScript (deprecated). Each one off by default to keep unused generators out of the build.

JVM toolchain

kotlin { jvmToolchain(17) } (or the equivalent compileOptions pair) pins the JDK used to compile Kotlin and Java sources, independent of whatever JDK launched Gradle itself.

Build Variants, Source Sets and Manifest Merging

A build variant is the cross product of every build type and every flavor dimension: TrailMate’s two build types (debug, release) times its one flavor dimension with two flavors (staging, production) yields four variants — stagingDebug, stagingRelease, productionDebug, productionRelease — each with its own Gradle tasks (assembleStagingDebug, bundleProductionRelease, …) and its own merged manifest, resources and BuildConfig.

Each variant assembles its sources by layering source sets from most specific to least specific:

src/
├── main/                    # shared by every variant
├── debug/                   # merged into every *Debug variant
├── release/                 # merged into every *Release variant
├── staging/                 # merged into every staging* variant
├── stagingDebug/            # merged only into the stagingDebug variant (highest priority)
├── test/                    # local JVM unit tests, all variants
└── androidTest/             # instrumented tests, all variants

A resource or manifest entry in a more specific source set (stagingDebug) overrides the same entry from a less specific one (debug, then main). Manifest merging applies the same priority order to AndroidManifest.xml fragments — a flavor’s manifest can add a <meta-data> or override an android:label without touching main's manifest — and Android Studio’s Merged Manifest view shows exactly which source contributed each attribute. See Android Developers — Configure Build Variants and Android Developers — Manage the Manifest File.

BuildConfig is a generated class (one per variant, gated by buildFeatures.buildConfig = true) exposing buildConfigField entries and standard fields (BuildConfig.APPLICATION_ID, BuildConfig.VERSION_NAME, BuildConfig.DEBUG, BuildConfig.FLAVOR, BuildConfig.BUILD_TYPE) as compile-time constants, letting code branch on BuildConfig.BASE_URL without any runtime configuration lookup.

gradle.properties and the Gradle Wrapper

gradle.properties holds JVM and Gradle daemon settings plus AGP/Kotlin feature flags, and is committed (unlike local.properties, which holds machine-specific paths and is never committed):

# gradle.properties
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

The Gradle wrapper (gradlew/gradlew.bat plus gradle/wrapper/gradle-wrapper.properties) pins the exact Gradle version (9.6) a project builds with, downloaded on first run, so every machine and CI runner builds with the identical Gradle regardless of what is installed globally. It is generated and upgraded with ./gradlew wrapper --gradle-version 9.6 and should always be committed. See Gradle — The Gradle Wrapper and Android Developers — Configure the JDK Used for Gradle.

Dependency Configurations and BOMs

Each dependencies {} entry names both a library and a configuration that controls its visibility and when it is needed:

Configuration Meaning

implementation

Available to compile and run this module, not re-exported to modules that depend on this one. The default choice; keeps a module’s dependency graph private and its own changes from forcing recompilation of consumers.

api

Like implementation, but re-exported to consumers' compile classpath. Only used on Gradle library modules whose public API genuinely needs a dependency’s types (for example :core:model exposing kotlinx.datetime types from its own public classes).

ksp

Registers an annotation processor that runs through KSP (Kotlin Symbol Processing) rather than kapt — ksp(libs.hilt.compiler), ksp(libs.androidx.room.compiler).

testImplementation

Only on the local-JVM test compile/runtime classpath (src/test/), run by ./gradlew test, executed on the host JVM against Robolectric or plain JVM code.

androidTestImplementation

Only on the instrumented-test classpath (src/androidTest/), run by ./gradlew connectedAndroidTest on a device or emulator.

debugImplementation

Only in debug builds — the standard way to add a debug-only tool (Compose’s ui-tooling, LeakCanary) without shipping it in release.

implementation(platform(libs.compose.bom)) declares a Bill of Materials: a single version (2026.09.00) that then governs every other androidx.compose.* artifact’s version implicitly, so compose-ui, compose-material3 and compose-ui-tooling-preview never drift out of sync with each other. Both Compose and OkHttp ship a BOM this way; declaring one BOM per ecosystem and letting individual artifact versions follow it is the recommended pattern over pinning each artifact’s version by hand. See Android Developers — Add Build Dependencies and Android Developers — Using the Compose BOM.

Common Gradle Tasks

Task What it does

./gradlew assembleDebug

Builds a debug .apk for every product flavor, without running tests.

./gradlew bundleRelease

Builds a signed .aab (Android App Bundle) for every flavor’s release variant, the format Play requires.

./gradlew test

Runs local JVM unit tests (src/test/) for every variant.

./gradlew connectedAndroidTest

Runs instrumented tests (src/androidTest/) on every connected device/emulator.

./gradlew lint

Runs Android Lint across every variant.

./gradlew :app:dependencies

Prints the resolved dependency tree for the :app module, useful for diagnosing version conflicts.

./gradlew --scan

Publishes a Build Scan with a detailed breakdown of what took time and why a task was or was not up to date.

Convention Plugins: build-logic

Repeating the same android {} block, JVM toolchain and lint configuration in every one of TrailMate’s seven modules invites drift. A convention plugin — an ordinary Gradle plugin, written once in an included build named build-logic, applied by every module that needs its shape — centralizes that instead:

// build-logic/convention/src/main/kotlin/trailmate.android.library.compose.gradle.kts
plugins {
    id("trailmate.android.library")
    id("org.jetbrains.kotlin.plugin.compose")
}

android {
    buildFeatures {
        compose = true
    }
}

dependencies {
    val bom = project.dependencies.platform("androidx.compose:compose-bom:2026.09.00")
    add("implementation", bom)
    add("androidTestImplementation", bom)
}

A feature module then reduces its own build.gradle.kts to:

// feature/trails/build.gradle.kts
plugins {
    id("trailmate.android.library.compose")
    id("trailmate.hilt")
}

android {
    namespace = "com.example.trailmate.feature.trails"
}

dependencies {
    implementation(project(":core:model"))
    implementation(project(":core:data"))
}

build-logic is registered as an included build from the root settings.gradle.kts (includeBuild("build-logic")), which is what lets its plugins be applied to other modules by id, as if they had been published to a repository, without actually publishing anything. This is the pattern behind Now in Android and most production multi-module Android projects. See Android Developers — Extend the Android Gradle Plugin and Gradle — Sharing Build Logic Between Subprojects.

AGP 9 Migration: the New Variant API and newDsl

AGP 9.0 removed the legacy variant API (applicationVariants, libraryVariants, mutable BaseVariant/ApplicationVariant objects read inside android {}) that plugins and build scripts used to hook into per-variant configuration. In its place, the Variant API (androidComponents { onVariants { …​ } }) exposes each variant as an immutable, lazily-evaluated model built for configuration-cache compatibility:

// app/build.gradle.kts -- reading and customizing variants under the new API
androidComponents {
    onVariants { variant ->
        println("Configuring variant: ${variant.name}")

        if (variant.buildType == "release") {
            variant.outputs.forEach { output ->
                if (output is com.android.build.api.variant.impl.VariantOutputImpl) {
                    output.outputFileName.set("trailmate-${variant.name}-${variant.versionName.get()}.apk")
                }
            }
        }
    }
}

Where old-API code read variant.buildType.buildConfigFields directly during configuration (breaking the configuration cache, since it captured live, mutable objects), onVariants receives a read-only snapshot and schedules any further work as a lazy Provider, so Gradle can safely cache and reuse the configuration between builds. AGP 9’s newDsl setting (on by default since 9.0) additionally changes how android {} itself is typed internally to support this; existing android {} blocks written the conventional way, as shown throughout this page, do not need to change. Projects still on the old variant API from AGP 8 or earlier should follow the official migration guide before upgrading further. See Android Developers — AGP 9.0.0 Release Notes, Android Developers — AGP 9.4.0 Release Notes and Android Developers — Extend the Android Gradle Plugin.

From kapt to KSP

kapt (Kotlin Annotation Processing Tool) ran a full, generated Java stub compilation of every Kotlin file just so Java-based annotation processors (like Room’s or Dagger’s original ones) could see them — slow, and a separate compilation pass per module. KSP (Kotlin Symbol Processing) gives compatible processors direct access to Kotlin’s own compiler symbols, without a Java-stub round trip, typically 2x or faster on processor-heavy modules. Room, Hilt, Moshi and most current-generation Android annotation processors ship a KSP-compatible artifact; the migration is usually just:

// before (kapt)
plugins {
    kotlin("kapt")
}
dependencies {
    kapt(libs.androidx.room.compiler)
}

// after (KSP)
plugins {
    alias(libs.plugins.ksp)
}
dependencies {
    ksp(libs.androidx.room.compiler)
}

kapt is in maintenance mode — it still works, and some older third-party processors have not published a KSP variant, but new projects should default to KSP and existing ones should migrate opportunistically, one processor at a time (both can coexist in the same module during a migration). See Android Developers — Migrate from kapt to KSP and Kotlin — Kotlin Symbol Processing (KSP).

The Configuration Cache and the Build Cache

Two independent caches make repeated builds faster:

  • The configuration cache (org.gradle.configuration-cache=true) skips re-running the configuration phase — evaluating every build.gradle.kts, resolving plugins {} blocks, building the task graph — when nothing that would change it has changed, serializing that graph to disk instead. It is why the old variant API (which read live, mutable objects during configuration) had to be replaced: anything the configuration cache serializes must be a stable, cacheable snapshot, which is exactly what onVariants provides.

  • The build cache (org.gradle.caching=true, plus a buildCache {} block in settings.gradle.kts for a shared remote cache in CI) reuses individual task outputs — a Kotlin compile task, a KSP run, a lint report — keyed by a hash of their inputs, whether that hash was last produced locally or, with a remote cache, by a teammate’s or CI’s build.

Both are additive: the configuration cache speeds up the phase that decides what to run, the build cache speeds up the phase that actually runs it, by skipping tasks whose inputs have not changed. See Android Developers — Optimize Your Build Speed and Gradle — Configuration Cache.

Figure: the Build Pipeline

flowchart LR A["Kotlin/Java sources
+ resources"] --> B["Compile
(Kotlin/javac,
KSP processors)"] B --> C["Dex
(D8/R8:
bytecode to DEX,
shrink/obfuscate release)"] C --> D["Package
(merge DEX,
resources, assets,
native libs into
APK/AAB)"] D --> E["Sign
(apksigner,
debug or release key)"] E --> F["Installable/publishable
artifact
(.apk / .aab)"]

Compiling happens once per variant’s merged source sets; D8 always converts JVM bytecode to Dalvik Executable (DEX) format, and R8 (D8’s superset) additionally shrinks, obfuscates and optimizes the code graph for release builds where isMinifyEnabled = true. Packaging then merges every variant’s DEX files, merged resources, assets and any native (.so) libraries into one .apk (installable directly) or .aab (the Play-only format that Play itself splits into per-device APKs). Signing — with the debug keystore for debug builds, or a release keystore for release — is what makes an artifact installable on a device (unsigned or debug-signed APKs cannot be published) and is covered in full in the section’s build/sign pages. See Android Developers — Gradle Build Overview and Android Developers — Shrink, Obfuscate and Optimize Your App.