← Back to all posts

05 Xpense

Gradle and the Build System

Xpense Dev Retrospective


What Gradle Actually Does

Writing Kotlin files and XML layouts doesn't produce an APK by itself. Something has to compile every file, process every resource, pull in every dependency, sign the result, and package all of it into one installable file. That something is Gradle.

Think of Gradle as the kitchen, not the recipe. The Kotlin files and layouts are the ingredients. Gradle is what actually assembles them into a finished dish, following whatever instructions the build files give it.


The Real Structure

Xpense is a single-module project. settings.gradle.kts names the project and includes exactly one module:

settings.gradle.kts
rootProject.name = "Xpense"
include(":app")

The top-level build.gradle.kts only declares which plugins exist in the project, without applying them yet:

build.gradle.kts (project root)
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.ksp) apply false
}

The actual work happens in app/build.gradle.kts, where the plugins get applied for real and every setting specific to Xpense lives.


SDK Versions

SettingXpense's actual value
compileSdk36
targetSdk36
minSdk26

minSdk 26 means Xpense won't even install on a phone older than Android 8.0. compileSdk 36 is which SDK version the code compiles against. targetSdk 36 is what the app tells Android it was built and tested for, which affects which OS behavior changes actually apply at runtime.

Already ahead of the deadline: Google requires new app updates to target API level 36 starting August 31, 2026, or the submission gets rejected. Xpense is already on 36, so this particular problem is already handled, not something waiting.


KSP, Not kapt

Room needs an annotation processor to generate the actual database code behind the DAOs at compile time. Xpense uses KSP (Kotlin Symbol Processing) for that, not the older kapt.

app/build.gradle.kts
implementation("androidx.room:room-runtime:2.7.2")
ksp("androidx.room:room-compiler:2.7.2")
implementation("androidx.room:room-ktx:2.7.2")
implementation("com.google.code.gson:gson:2.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")

Why KSP over kapt: kapt works by generating Java stubs for Kotlin code so older Java-based annotation processors can run against it, which is slower and adds a build step. KSP processes Kotlin directly. Room has supported KSP for a while now, and there was no reason to keep using the slower path.

Why room-ktx specifically: it's what adds db.withTransaction { }, which is what makes backup restore in DataActivity atomic instead of a sequence of separate writes that could partially fail.


Room's Schema Export

Room can dump a JSON snapshot of the database schema on every build, which is what makes writing correct Migration objects possible later instead of guessing.

app/build.gradle.kts
ksp {
    arg("room.schemaLocation", "$projectDir/schemas")
}

buildFeatures and the BuildConfig Surprise

AboutActivity reads BuildConfig.VERSION_NAME to show the app version. That used to be generated automatically for free. AGP 9.2.1, which Xpense is on, turns it off by default.

app/build.gradle.kts
buildFeatures {
    buildConfig = true
}

Without that line, referencing BuildConfig.VERSION_NAME just fails to compile. It isn't a rare edge case, it's the default on any recent AGP version.


Debug and Release Are Genuinely Different Apps

Build typeWhat's different
debugapplicationIdSuffix = ".debug", no minification
releasesigned with the real keystore, isMinifyEnabled and isShrinkResources both true, real proguard rules applied

What the debug suffix actually buys: a debug build installs as com.hassanbukhari.xpense.debug, a different package from the real com.hassanbukhari.xpense. That means a debug build and the real Play Store release can be installed side by side on the same phone at the same time, without one overwriting the other. Useful for testing a change without losing the actual data in the installed release app.


Minification and What proguard-rules.pro Actually Protects

Release builds run R8, which strips unused code and renames classes to shrink the APK. Left unchecked, this can break reflection-based libraries like Room and Gson, which is exactly what proguard rules exist to prevent.

proguard-rules.pro
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.hassanbukhari.xpense.data.BackupData { *; }
-keep class com.hassanbukhari.xpense.data.Expense { *; }
-keep class com.hassanbukhari.xpense.data.Category { *; }
-keep class com.hassanbukhari.xpense.data.*_Impl { *; }

-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken

The first block keeps the data classes and Room's generated *_Impl classes intact, since Room and Gson both rely on reflecting over field names, and a renamed field silently breaks serialization instead of throwing an obvious error. The last two lines exist because of something that actually went wrong in production, R8 was silently stripping Gson's TypeToken generic signatures, which broke the legacy JSON migration. That story gets its own post later in this series.


Signing Config, Kept Out of the Repo

app/build.gradle.kts
signingConfigs {
    create("release") {
        val keystorePropertiesFile = rootProject.file("keystore.properties")
        val keystoreProperties = Properties()
        if (keystorePropertiesFile.exists()) {
            keystoreProperties.load(FileInputStream(keystorePropertiesFile))
            storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
            storePassword = keystoreProperties["storePassword"] as String
            keyAlias = keystoreProperties["keyAlias"] as String
            keyPassword = keystoreProperties["keyPassword"] as String
        }
    }
}

This block reads credentials from keystore.properties, a file that stays local and is excluded from git. This build.gradle.kts file itself is completely safe to keep in a public repo, because it contains no actual password or key, only the logic to load one from somewhere that never gets committed. Signing gets its own dedicated post later in this series.


Closing

Gradle turns all of this into the actual APK. Next up, how the screens themselves are described in the first place: Layouts and XML UI.

← Previous 04 - AndroidManifest.xml Explained Next → 06 - Layouts and XML UI