RizTech Academy logo
RizTech Academy
How an Android App Is Put TogetherLesson 2 of 635 min

Gradle, the version catalog and build variants

Here is a real build failure, from the Mandi Bhav starter with one number changed. compileSdk was set to 36, the build ran for two seconds, and stopped with fourteen messages like this one:

> Task :app:checkDebugAarMetadata FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:checkDebugAarMetadata'.
> 14 issues were found when checking AAR metadata:

    10.  Dependency 'androidx.core:core:1.19.1' requires libraries and applications that
         depend on it to compile against version 37 or later of the
         Android APIs.

         :app is currently compiled against android-36.

         Recommended action: Update this project to use a newer compileSdk
         of at least 37, for example 37.2.

         Note that updating a library or application's compileSdk (which
         allows newer APIs to be used) can be done separately from updating
         targetSdk (which opts the app in to new runtime behavior) and
         minSdk (which determines which devices the app can be installed
         on).

Every word of that is about Gradle, the libraries it downloads, and three SDK numbers that sound alike and mean very different things. By the end of this lesson you will be able to read it, fix it, and explain the note at the bottom.

What Gradle does

Gradle is a build tool: it turns your source folder into an APK. It does that as a graph of tasks, each with inputs and outputs, and it runs only the tasks whose inputs changed. Run a debug build from the project folder and watch:

./gradlew assembleDebug --console=plain
> Task :app:preBuild UP-TO-DATE
> Task :app:generateDebugResources
> Task :app:checkDebugAarMetadata
> Task :app:mergeDebugResources
> Task :app:processDebugMainManifest
> Task :app:processDebugResources
> Task :app:compileDebugKotlin
> Task :app:dexBuilderDebug
> Task :app:mergeProjectDexDebug
> Task :app:packageDebug
> Task :app:assembleDebug

BUILD SUCCESSFUL in 3s
37 actionable tasks: 37 executed

That is trimmed; the real list is 37 tasks. You can read the pipeline off the names: check the libraries, merge the resources, merge the manifest, compile the Kotlin, convert it to Android's DEX format, package the APK. Run the same command again with nothing changed:

> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:packageDebug UP-TO-DATE
> Task :app:assembleDebug UP-TO-DATE

BUILD SUCCESSFUL in 405ms
36 actionable tasks: 36 up-to-date

Three seconds, then under half a second. The very first build on a fresh machine took about 1 minute 22 seconds, nearly all of it downloading Gradle, the Android plugin and the libraries. Slow first, fast after, is how Gradle is supposed to feel; if every build is slow, something is defeating the up-to-date checks.

On Windows the command is gradlew.bat assembleDebug. Android Studio's Run button runs the same tasks for you.

The wrapper, and why you never install Gradle yourself

./gradlew --version
------------------------------------------------------------
Gradle 9.9.0
------------------------------------------------------------

Kotlin:        2.4.21
Launcher JVM:  17.0.17 (Homebrew 17.0.17+0)

You did not install Gradle 9.9.0. ./gradlew read gradle/wrapper/gradle-wrapper.properties, downloaded that exact version once to your home folder, and ran it. You could install Gradle with a package manager, and you would get whatever version the package manager has this month. Then your build and your teammate's build would differ, and "works on my machine" would be literally true. The wrapper pins the version in a committed file. Always run ./gradlew, never a gradle command from your PATH.

The version catalog

gradle/libs.versions.toml has three tables:

[versions]
agp = "9.5.1"
kotlin = "2.4.21"
coreKtx = "1.19.1"
composeBom = "2026.09.00"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

(Trimmed; the starter lists more libraries.) Gradle turns each name into a typed accessor, swapping dashes for dots: androidx-core-ktx becomes libs.androidx.core.ktx, and android-application becomes libs.plugins.android.application. The build file then reads:

dependencies {
    implementation(libs.androidx.core.ktx)
}

The point is one place to change a version, and autocomplete instead of typo-prone strings. The cost is one more file to look in; when a beginner asks "where is the version?", the answer is never the build file.

Plugins and libraries are different things

Plugin Library
What it is Code that runs during the build Code that runs in your app, on the phone
Declared in plugins { } dependencies { }
Examples here com.android.application, org.jetbrains.kotlin.plugin.compose core-ktx, activity-compose, material3
Ends up in the APK? No Yes, and it costs APK size

The Android Gradle Plugin (AGP, here 9.5.1) is what teaches Gradle about APKs, manifests and resources. In AGP 9, Kotlin support is built in, so there is no separate Kotlin Android plugin; the only Kotlin plugin is the Compose compiler plugin. If you follow an older tutorial that adds org.jetbrains.kotlin.android, that is why it does not match.

The BOM

Look at the Compose lines in app/build.gradle.kts:

implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)

The Compose libraries have no versions. The BOM (bill of materials) is a list of versions known to work together, and platform(...) tells Gradle to take versions from it. Compose BOM 2026.09.00 chose Compose UI 1.12.1 and Material 3 1.4.0 here: different numbers, tested as a set. You upgrade the whole family by changing one line in the catalog. What a BOM is not: it does not add any library to your app on its own. You still list each one you use.

compileSdk, targetSdk and minSdk

android {
    compileSdk {
        version = release(37) {
            minorApiLevel = 2
        }
    }

    defaultConfig {
        minSdk = 26
        targetSdk = 36
    }
}
Setting Question it answers What changing it costs
compileSdk Which Android APIs can my code see when it compiles? Nothing at runtime. Raise it freely; libraries often require it.
targetSdk Which Android version's behaviour have I tested against? Raising it opts in to new rules (permissions, background limits), so it needs testing.
minSdk What is the oldest Android that can install the app? Lower means more phones and more code paths to test; higher cuts users off.

Now the opening failure makes sense. androidx.core 1.19.1 was built against Android API 37 and says so in its metadata. Gradle's checkDebugAarMetadata task compared that with your compileSdk of 36 and refused, because the library may call APIs your compile step cannot see. The note at the bottom is the most useful sentence in the message: you can raise compileSdk without touching targetSdk or minSdk. Mandi Bhav compiles against 37, targets 36, and still installs on Android 8.0 (API 26) phones, which matters when your users are farmers on old handsets.

Android now has minor API levels as well as major ones, and AGP 9 has a block to say so: release(37) { minorApiLevel = 2 } means API 37.2. The plain compileSdk = 37 form still exists, but it means 37.0, and on a machine that has only the 37.2 platform installed it fails:

> Failed to find target with hash string 'android-37.0' in: /Users/priya/Library/Android/sdk

Use the block form, and install the platform it names through the SDK Manager.

Run lint (./gradlew lintDebug) and it warns that targetSdk = 36 is "Not targeting the latest versions of Android". That warning is correct and deliberate here: raising targetSdk is a decision you make after testing the new behaviour, not a number you bump to silence a warning.

Build types: debug and release

Every Android project has two build types whether you write them or not. Debug is signed with a throwaway key, can be attached to a debugger, and is what Run installs. Release is what you ship: signed with your real key, optionally shrunk by R8, not debuggable.

The thing you will most often want to differ is the server. During development Mandi Bhav should fetch prices from a test server, and the shipped app from the real one. A buildConfigField writes a constant into a generated class called BuildConfig, with a different value per build type:

android {
    buildTypes {
        debug {
            applicationIdSuffix = ".debug"
            buildConfigField("String", "API_BASE_URL", "\"https://staging.example.in/api/\"")
        }
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
            buildConfigField("String", "API_BASE_URL", "\"https://api.example.in/\"")
        }
    }
    buildFeatures {
        compose = true
        buildConfig = true
    }
}

Then, anywhere in the app:

object ApiConfig {
    val baseUrl: String = BuildConfig.API_BASE_URL
    val isDebug: Boolean = BuildConfig.DEBUG
}

applicationIdSuffix = ".debug" makes the debug app com.riztechacademy.mandibhav.debug, so a tester can have the debug and the Play Store build installed side by side.

There are two traps, and both were hit while writing this lesson. First, BuildConfig generation is off by default in current AGP. Add a field without buildConfig = true and the build stops during configuration:

> Build Type 'debug' contains custom BuildConfig fields, but the feature is disabled.
  To enable the feature, add the following to your module-level build.gradle:
  `android.buildFeatures.buildConfig = true`

Second, the value is pasted into generated Java source as written. A String field needs quotes inside the string, hence "\"https://...\"". Leave them out and you get a compile error in a file you never wrote:

BuildConfig.java:14: error: ';' expected
  public static final String API_BASE_URL = https://staging.example.in/api/;

What BuildConfig is not for: secrets. It ends up in the APK, and anyone can unpack an APK and read it. A base URL is fine. A private API key is not.

Product flavours, briefly

Build types change how an app is built. Product flavours make different versions of the app from one codebase: a free and a paid edition, or a version per client. Suppose Mandi Bhav later ships a separate edition for traders:

android {
    flavorDimensions += "audience"
    productFlavors {
        create("farmer") {
            dimension = "audience"
        }
        create("trader") {
            dimension = "audience"
            applicationIdSuffix = ".trader"
        }
    }
}

Every flavour combines with every build type, giving four build variants: farmerDebug, farmerRelease, traderDebug, traderRelease, each with its own tasks, such as assembleTraderDebug. The trader debug build's generated BuildConfig shows both suffixes stacking up:

public static final String APPLICATION_ID = "com.riztechacademy.mandibhav.trader.debug";
public static final String BUILD_TYPE = "debug";
public static final String FLAVOR = "trader";
// Field from build type: debug
public static final String API_BASE_URL = "https://staging.example.in/api/";

The cost: every flavour doubles your variants, your build time for assemble, and your testing. Mandi Bhav does not need flavours and will not use them. Add them when you have two products, not two settings.

Listing the tasks

./gradlew tasks

lists the tasks you can run, grouped as Build, Install, Verification and so on, with a hint to add --all for every variant task. With the exact toolchain this course uses (AGP 9.5.1), running it on the starter failed instead:

> Could not create task ':app:generateDebugComposePreviewRunfiles'.
   > Compose Preview failed for ':app' (variant 'debug'): Android resources are
     excluded from unit tests ('testOptions.unitTests.isIncludeAndroidResources = false'
     in finalizeDsl). Compose Preview requires compiled Android resources to
     resolve layout XML and theme attributes.

Building works; only the task listing breaks, because listing tasks has to create every task, including a Compose Preview task this plugin version adds. If you see it, two settings satisfy that task. In app/build.gradle.kts, inside android { }:

testOptions {
    unitTests {
        isIncludeAndroidResources = true
    }
}

and in gradle.properties:

android.onlyEnableUnitTestForTheTestedBuildType=false

The cost is small: unit-test tasks for the release build type, and slightly slower unit tests. If a later plugin version makes ./gradlew tasks work without them, leave them out. Either way, the habit stands: read the whole error, find the setting it names, and change that.

Check your work

Why ./gradlew and not gradle? The wrapper downloads and runs the exact version pinned in the committed properties file, so every machine builds the same way.

What turns androidx-core-ktx into libs.androidx.core.ktx? The version catalog; dashes in the name become dots in the accessor.

Plugin or library: material3? com.android.application? material3 is a library and ships in the APK; com.android.application is a plugin and runs only during the build.

What does platform(libs.androidx.compose.bom) do? Supplies tested-together versions for the Compose libraries; it adds no library by itself.

Why did compileSdk = 36 fail? androidx.core 1.19.1 declares that apps must compile against API 37 or later, and checkDebugAarMetadata enforces it.

Does raising compileSdk change which phones can install the app? No. That is minSdk. Behaviour changes are targetSdk.

Why the doubled quotes in buildConfigField? The value is pasted into Java source, so a String needs its quotes inside the value.

How many variants do two flavours and two build types give? Four.

Practice 1: which tasks re-run after editing MainActivity.kt? compileDebugKotlin and the tasks downstream of it (dexing and packaging). The resource and manifest tasks stay UP-TO-DATE, because their inputs did not change.

Practice 4: which stage did each error come from? The disabled-feature error comes from configuring the project, before any task runs ("A problem occurred configuring project ':app'"). The missing-quotes error comes from a task, compileDebugJavaWithJavac, compiling the generated file.

Practice

  1. Run ./gradlew assembleDebug twice and compare the times and the UP-TO-DATE lines. Then change one word in MainActivity.kt and run it again. Which tasks re-ran?
  2. Change the Compose BOM in libs.versions.toml to an older release, sync, and look in the build output or the External Libraries list for the Material 3 version it chose. Put it back.
  3. Add the API_BASE_URL field to both build types with buildConfig = true, build, and open app/build/generated/source/buildConfig/debug/com/riztechacademy/mandibhav/BuildConfig.java. Find your value, and the comment saying which build type it came from.
  4. Break it on purpose: remove buildConfig = true and build. Then put it back and remove the inner quotes from one URL. Read both errors and say which stage of the build each one came from.
  5. Break it on purpose: set compileSdk = 36. Count the issues, find androidx.core among them, and restore the block.
  6. Harder: add the farmer and trader flavours, run ./gradlew assemble, and list the APKs under app/build/outputs/apk/. Then remove the flavours again, because Mandi Bhav does not need them, and write one sentence on what keeping them would have cost.

Official documentation

Next: the manifest, the file that tells Android what your app is before a line of your code runs.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship