Dev.to Security 🔐 Cybersecurity 👁 0 📖 16 min read

Release Build Autopsy: Following One Kotlin Line from Gradle to the First Frame

Picture a Friday afternoon. The debug build of your app is flawless. You cut a release, and within an hour crash reports arrive: a ClassNotFoundException for a payment plugin that definitely exists in the repo. On Monday

Release Build Autopsy: Following One Kotlin Line from Gradle to the First Frame

Picture a Friday afternoon. The debug build of your app is flawless. You cut a release, and within an hour crash reports arrive: a ClassNotFoundException for a payment plugin that definitely exists in the repo. On Monday CI takes fourteen minutes for a one-line change, and nobody can say why. Meanwhile, product wants cold start under a second on a three-year-old phone.

Three different complaints, one root cause: the build-and-runtime pipeline is a black box to most of the team. This post opens it. We follow a single Kotlin line from ./gradlew assembleRelease through Gradle, AAPT2, R8, signing, Play delivery, and finally ART on the device, and at each stage we look at how it actually fails in production.

Every code sample uses a fictional app, Harborlane, a ferry-ticketing client. Package names are dev.harborlane.*.

assemble-release-journey

The rest of the article walks that picture left to right, top to bottom.

1. Gradle: the planner that runs before anything compiles

The three phases

Gradle never “just builds”. It works in three distinct phases:

  1. Initialization. Gradle reads settings.gradle.kts and decides which projects take part.
  2. Configuration. Every participating project’s build script is evaluated, tasks are registered, and the task graph (a directed acyclic graph) is assembled.
  3. Execution. Only the tasks required by the requested target run, and each one first asks, “Do I even need to?”

gradle-lifecycle

The key asymmetry: execution has long been cache-friendly (up-to-date checks, the build cache), while configuration historically was not. The configuration cache (stable since Gradle 8.1, opt in with org.gradle.configuration-cache=true) changes that by serializing the result of the configuration phase and skipping script evaluation entirely on a hit. But a hit requires unchanged inputs and plugins and scripts that are compatible with it, which is exactly where configuration-time work bites. On a miss, whatever your scripts do while being evaluated is paid for on every command, including ./gradlew help, IDE syncs, and CI shards.

Failure mode: the configuration-time tax

This is a real pattern that has quietly added minutes to builds:

// app/build.gradle.kts: evaluated on EVERY Gradle invocation
val gitSha: String = Runtime.getRuntime()
    .exec(arrayOf("git", "rev-parse", "--short", "HEAD"))
    .inputStream.bufferedReader().readText().trim()

android {
    defaultConfig {
        buildConfigField("String", "GIT_SHA", "\"$gitSha\"")
        // Worse: a value that is different on every run
        buildConfigField("long", "BUILT_AT", "${System.currentTimeMillis()}L")
    }
}

There are two separate sins here:

  • The process spawn happens at configuration time, in every project that applies the script, even when you are only asking Gradle for its help text.
  • BUILT_AT changes every run, so the generated BuildConfig is never up to date. Everything downstream (compilation, dexing, packaging) is invalidated, and the build cache can never produce a hit for it.

The fix is to model the value as a lazy provider and feed it to a task as a declared input:

// build-logic or app/build.gradle.kts
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*

@CacheableTask
abstract class GenerateBuildInfo : DefaultTask() {

    @get:Input
    abstract val gitSha: Property<String>

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    @TaskAction
    fun generate() {
        val file = outputDir.get().asFile.resolve("dev/harborlane/BuildInfo.kt")
        file.parentFile.mkdirs()
        file.writeText(
            """
            package dev.harborlane

            internal object BuildInfo {
                const val GIT_SHA = "${gitSha.get()}"
            }
            """.trimIndent()
        )
    }
}

val gitShaProvider: Provider<String> = providers
    .exec { commandLine("git", "rev-parse", "--short", "HEAD") }
    .standardOutput.asText
    .map { it.trim() }

androidComponents.onVariants { variant ->
    val taskName = "generate${variant.name.replaceFirstChar(Char::uppercase)}BuildInfo"
    val generate = tasks.register<GenerateBuildInfo>(taskName) {
        gitSha.set(gitShaProvider)
    }
    variant.sources.java?.addGeneratedSourceDirectory(generate, GenerateBuildInfo::outputDir)
}

Three idioms matter here: tasks.register instead of create (the task is only configured if it is actually needed), Property/Provider instead of eager values, and annotated inputs and outputs so Gradle can fingerprint the task. providers.exec is the configuration-cache-compatible replacement for Project.exec: it returns a lazy provider, and the process only runs when something resolves that provider (here, when Gradle fingerprints the task's @Input). The resolved value is then recorded as a configuration cache input, so a new commit correctly invalidates the cache entry. If you ever need the value during configuration (for example for buildConfigField), wrap the command in a custom ValueSource so the cache can track it, and accept that this value is read at configuration time. One honest caveat: a commit hash as an input means the generated file changes on every commit. Keep it tiny and isolated in its own module so only a small slice recompiles.

Up-to-date checks, incremental builds, and the build cache

Three mechanisms often get lumped together; they are different:

  • UP-TO-DATE: inputs and outputs of this task are unchanged in this working directory, so skip it.
  • Incremental task: the task does run, but processes only the changed inputs (Kotlin compilation works this way).
  • FROM-CACHE: a task with identical input fingerprints ran somewhere else (another branch, another CI agent), so its outputs are restored from the build cache.

Cache hits depend on fingerprints being deterministic and relocatable. The usual saboteurs are absolute paths baked into inputs, timestamps, random UUIDs, and un-normalized line endings. If your remote cache hit rate is poor, run a build twice on different checkouts with -Dorg.gradle.caching.debug=true, or compare Build Scans, to see which input fingerprint differs.

Build variants

Build types and product flavors form a matrix. Each cell is a complete, buildable variant with its own tasks and its own source set.

android {
    namespace = "dev.harborlane.app"
    compileSdk = 36

    defaultConfig { minSdk = 23 }

    flavorDimensions += "env"
    productFlavors {
        create("dev") {
            dimension = "env"
            applicationIdSuffix = ".dev"
            resValue("string", "app_name", "Harborlane Dev")
        }
        create("prod") { dimension = "env" }
    }

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

variant-matrix

When the same file exists in several source sets, the most specific one wins: src/devDebug beats src/debug or src/dev, which beat src/main. That is a feature (swap an API endpoint per environment) and a trap (a stray file in src/dev silently shadows production behavior in only one variant).

Debugging a slow build, in order

  1. ./gradlew assembleRelease --dry-run shows which tasks would run and in what order, without running them.
  2. ./gradlew assembleRelease --scan gives a timeline and a per-task cache verdict.
  3. Android Studio’s Build Analyzer points at plugins and tasks that always rerun.
  4. Compare configuration time against execution time first. If help itself is slow, the problem is in your scripts, not your compiler.

2. Resources and manifests: two merges before a single class compiles

AAPT2 in two steps

AAPT2 is split into compile and link so that it can be incremental:

  • Compile converts each resource file into an intermediate .flat unit. Edit one layout and only that unit is redone.
  • Link takes all .flat files plus the merged manifest and produces the final resources.arsc, the resource-ID table, and the generated R class (for AAB, a protobuf-format variant).

A resource ID is a 32-bit integer in the form 0xPPTTEEEE: package (0x7f for your app), resource type, and entry. IDs are assigned at link time, which is why they must never be persisted or hard-coded, and why library modules cannot treat their R fields as true constants.

resources-and-manifest

Failure mode: the resource shrinker and names it cannot see

isShrinkResources = true removes resources that nothing references. By default the shrinker runs in a forgiving safe mode: if it finds string constants that look like the prefix of a resource name (for example "banner_" passed to getIdentifier), it conservatively keeps every resource matching that pattern. That heuristic is why many apps never notice the problem, and also why the failure below shows up only when the pattern is invisible:

// routeAsset arrives from remote config, e.g. "banner_summer_sale"
fun bannerFor(context: Context, routeAsset: String): Int =
    context.resources.getIdentifier(routeAsset, "drawable", context.packageName)

The full name is built outside the app, so the dex contains no recognizable constant prefix and the shrinker has nothing to match. Banners vanish in release only, and getIdentifier returns 0. The same happens if you opt into tools:shrinkMode="strict", which disables the heuristics on purpose. Declare the dynamic family explicitly:

<!-- res/raw/keep.xml -->
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@drawable/banner_*" />

Better still, map the allowed values through an explicit when over real R.drawable references; the compiler and the shrinker then both see the dependency, and an unknown value becomes a handled case instead of a silent 0.

Manifest merging and conflict resolution

The merger combines the manifests of your build variant, your main source set, and every library, with priority in that order. When two sources disagree on the same attribute and the higher-priority one does not say it wins, the build fails with a “Manifest merger failed” error. Resolve it deliberately, using the tools namespace:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- A transitive analytics SDK declares this; we don't want it -->
    <uses-permission
        android:name="android.permission.READ_PHONE_STATE"
        tools:node="remove" />

    <application
        android:allowBackup="false"
        android:label="@string/app_name"
        tools:replace="android:allowBackup,android:label" />
</manifest>

Use tools:replace for individual attributes and tools:node="remove" or "replace" for whole elements. Treat every marker as documentation of a decision, and check the Merged Manifest tab in Studio after adding a dependency, because a library can silently contribute permissions, exported components, or usesCleartextTraffic.

3. Compilation, desugaring, and shrinking: D8 versus R8

The path from text to dex

Kotlin and Java sources are compiled by kotlinc and javac into JVM .class files. Android does not run those. A dex tool translates them into register-based DEX bytecode for the runtime:

  • D8 is the dexer. It converts classes to .dex and also desugars newer language constructs for older API levels.
  • R8 is a superset used on minified builds. It does whole-program shrinking (removes unreachable code), optimization (inlining, merging, devirtualization), and obfuscation (renaming), and performs the dexing and desugaring itself.

source-to-dex

Failure mode: modern java.time on an older device

Desugaring covers language features cheaply, but library APIs need the runtime to contain them. Code that compiles against java.time can still crash on API 23 unless you opt into library desugaring:

android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}

Without it, the symptom is NoClassDefFoundError or NoSuchMethodError on specific API levels, and nothing in your emulator on API 35 will ever reproduce it. Keep one low-API device or emulator in your release checklist.

Failure mode: R8 removes or renames something you reach by name

R8 can only reason about what code references statically. Reflection, string-based class lookup, JNI, and serialization frameworks reach code in ways it cannot see. Here is a plugin loader driven by remote config:

interface CheckoutPlugin {
    val id: String
    fun launch(context: Context)
}

object PluginLoader {
    // className arrives from remote config, e.g. "dev.harborlane.pay.SepaPlugin"
    fun load(className: String): Result<CheckoutPlugin> = runCatching {
        val plugin = Class.forName(className)
            .getDeclaredConstructor()
            .newInstance()
        plugin as? CheckoutPlugin
            ?: error("$className does not implement CheckoutPlugin")
    }
}

In the debug build this works. In release, no call site mentions SepaPlugin, so R8 either drops it or renames it, and the lookup fails with ClassNotFoundException (or NoSuchMethodException if the class survived without its constructor). Returning a Result keeps that failure observable and recoverable instead of crashing checkout.

r8-keep-rules

The fix is a rule that describes the relationship, not one class, so new plugins are covered automatically:

# proguard-rules.pro
-keep class * implements dev.harborlane.checkout.CheckoutPlugin {
    <init>();
}

Note the explicit <init>(). Since AGP 8, R8's full mode is the default, and in full mode R8 no longer assumes a default constructor must be kept just because a class is kept. Rules written against older behavior can break after an upgrade.

The same logic applies to JSON models parsed by reflection:

data class OrderDto(
    val orderId: String,
    val totalMinor: Long,
    val lines: List<LineDto>,
)

If Gson maps by field name and R8 renames orderId to a, the JSON key no longer matches. The result is not a crash at the parse site but a silent null or 0, which is worse:

-keepattributes Signature, *Annotation*
-keepclassmembers class dev.harborlane.data.remote.dto.** { <fields>; }

A senior-level observation: reaching for more keep rules is the second-best fix. Prefer libraries whose generated code is statically visible (Moshi codegen, kotlinx.serialization, Retrofit with a converter that does not need reflection on your models), because then R8 can do its job without your help and shrinks more of your app. Also, check whether a library ships its own consumer rules; if you author a library, publish them with consumerProguardFiles.

A repeatable R8 triage routine

  1. Reproduce on a minified build. Add a staging build type that sets isMinifyEnabled = true and signs with the debug key, and run your instrumented tests against it in CI.
  2. De-obfuscate the stack trace with the mapping.txt produced by that exact build: retrace mapping.txt crash.txt. Upload the mapping file to Play Console or your crash tool for every release.
  3. Ask R8 why something exists with -whyareyoukeeping class dev.harborlane.pay.SepaPlugin, and inspect -printusage and -printseeds output to see what was removed and what your rules matched.
  4. Fix the rule at the narrowest scope that survives refactoring, and add a test that loads the class by name.

4. Packaging, signing, and distribution

APK versus AAB

An APK is an installable artifact. An Android App Bundle is a publishing format: you upload it, and Google Play generates APKs tailored per device from it: a base APK plus configuration splits for screen density, CPU ABI, and language.

aab-delivery

The practical outcomes are smaller downloads and the ability to ship optional dynamic feature modules later.

Failure mode: the in-app language switcher that does nothing

Because language splits are delivered only for the device’s configured languages, an app that lets users pick a language inside the app may find the strings for the chosen language were never installed. Either rely on the platform per-app language support or opt out of language splitting:

android {
    bundle {
        language { enableSplit = false }
    }
}

Signature schemes, V1 through V4

signing-timeline

Practical consequences:

  • AGP signs with V1 only when minSdk is below 24, and always with the newer schemes for the device generation they support.
  • V4 is stored in a separate .idsig file and exists for incremental installation workflows.
  • With Play App Signing, you sign uploads with an upload key and Google re-signs the delivered APKs with the app signing key. Losing the upload key is recoverable. Losing a self-managed signing key without rotation is not.

zipalign and ordering

zipalign aligns uncompressed entries inside the archive so they can be memory-mapped efficiently. Because V2 and later sign the archive as a whole, the sequence is align first, sign second. If you script packaging manually and reverse the order, signature verification fails. AGP does it correctly for you, which is one more reason to avoid hand-rolled packaging.

5. ART: what happens after install

From Dalvik to a hybrid runtime

art-evolution

Early Android used Dalvik with a JIT. Android 5.0 made ART the runtime and compiled everything ahead of time during install, which gave fast execution but slow installs and system updates and noticeable storage cost. From Android 7.0, ART became a hybrid:

  1. The app starts in the interpreter, so install is quick.
  2. Hot methods are JIT-compiled and the runtime records a profile of what was hot.
  3. When the device is idle and charging, a background job (dex2oat) AOT-compiles the profiled methods.
  4. Subsequent launches execute native code for exactly the code that mattered.

The weakness is obvious: the first launches, which are the ones that shape a user’s first impression and your retention numbers, happen before any profile exists.

Baseline Profiles close the cold gap

A Baseline Profile is a list of classes and methods, shipped inside your APK or bundle, that tells ART which code is critical for startup and common journeys. At install time ART can compile those entries ahead of time, so the very first launch already runs the hot path as native code.

baseline-profile-effect

Google’s documentation cites improvements on the order of 30 percent in code execution speed from first launch; treat that as a ceiling-style claim and measure your own app, because the gain depends on how much of your startup path is interpreted code.

Generating the profile is a Macrobenchmark-style test that exercises your critical user journeys. (includeInStartupProfile needs a reasonably recent benchmark-macro-junit4, 1.2 or later. Run the generator against the non-minified release-like variant the Baseline Profile plugin creates, so view IDs and class names are intact. Note the explicit checkNotNull: without it a missing view fails silently and you ship a profile that never saw your real screen.)

@RunWith(AndroidJUnit4::class)
class StartupProfileGenerator {

    @get:Rule
    val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(
        packageName = "dev.harborlane.app",
        includeInStartupProfile = true,
    ) {
        pressHome()
        startActivityAndWait()

        // Exercise the first real screen, not just the splash
        val list = device.wait(
            Until.findObject(By.res(packageName, "departures_list")), 5_000
        )
        checkNotNull(list) { "departures_list never appeared; the profile would miss the real startup path" }
        list.fling(Direction.DOWN)
    }
}

And you verify the benefit with a benchmark that compares compilation modes:

@RunWith(AndroidJUnit4::class)
class ColdStartBenchmark {

    @get:Rule
    val rule = MacrobenchmarkRule()

    @Test fun noCompilation() = coldStart(CompilationMode.None())

    @Test fun withProfile() =
        coldStart(CompilationMode.Partial(BaselineProfileMode.Require))

    private fun coldStart(mode: CompilationMode) = rule.measureRepeated(
        packageName = "dev.harborlane.app",
        metrics = listOf(StartupTimingMetric()),
        iterations = 10,
        startupMode = StartupMode.COLD,
        compilationMode = mode,
    ) {
        pressHome()
        startActivityAndWait()
    }
}

Failure modes worth knowing

  • A profile that covers the splash screen only. Your journeys decide the value of the profile. Cover the first screen users really see, scrolling, and one or two common navigations.
  • Benchmarking a debuggable build or an emulator. Numbers from either are misleading. Use a release-like, non-debuggable variant on a real device.
  • Stale profiles after big refactors. Entries match class and method identities, so major renames or module moves reduce coverage; regenerate as part of releases, through the Baseline Profile Gradle plugin, rather than committing a file once and forgetting it.
  • Sideloaded builds. Outside Play, the profileinstaller library is what lets the profile be installed from the APK; keep that dependency in.

6. The interview answers, in plain language

“What happens between./gradlew assembleRelease and the final artifact?" Gradle initializes the build, configures all projects, and constructs the task graph. Then tasks run in dependency order: resources are compiled and manifests merged, AAPT2 links resources and assigns IDs, Kotlin and Java compile to .class files, R8 shrinks, optimizes, obfuscates, and dexes them, the packaging task zips everything, aligns it, and signs it. Each task is skipped when up to date or restored from cache when its inputs match a previous run. For Play delivery, bundleRelease produces an AAB instead, which Play turns into per-device APKs.

“Why did R8 remove an essential class or method?” R8 keeps only what is reachable by static analysis from entry points such as manifest components and your keep rules. If the class is reached only through reflection, a string, JNI, or a serializer, it looks unreachable, so it is deleted or renamed. A keep rule declares the dynamic entry point to R8: -keep for classes and members that must survive intact, -keepclassmembers when members matter only if the class survives, and -keepnames when only the name is the problem. The better long-term answer is to remove the reflection.

“How do Baseline Profiles improve startup?” ART normally learns what is hot only after the app has run for a while. A Baseline Profile ships that knowledge with the app, so critical classes and methods can be compiled ahead of time at install, and the first launches skip most interpretation and JIT warm-up.

A pre-release checklist you can steal

  • Run help and a no-op build; if either is slow, hunt configuration-time work.
  • Confirm no timestamps, absolute paths, or random values leak into task inputs.
  • Build and test the minified variant on CI, not only debug.
  • Archive mapping.txt for every shipped build.
  • Open the merged manifest after every dependency bump.
  • Test on your lowest supported API level with desugaring enabled.
  • Verify language, density, and ABI splits with a real bundle install (bundletool or an internal-testing track).
  • Keep a Baseline Profile in the release pipeline and benchmark it against CompilationMode.None().

The pipeline is not magic; it is a chain of narrow, inspectable transformations. Once you can say which link failed, “the release build is weird” becomes a ten-minute investigation instead of a lost weekend.

🍎 Share Your Kotlin Code with iOS — Without Rewriting Your App

If “we’ll just build it twice, once per platform” describes how your team ships mobile, I’d recommend “Kotlin Multiplatform for Android Developers” — a 22-chapter, hands-on guide that goes past the sample-project demos and follows the shared module all the way to two app store submissions. No iOS or Swift background required.

It walks through the expect/actual mechanism and source set hierarchy, shared architecture, networking with Ktor, persistence with Room KMP and SQLDelight, Koin dependency injection, Flow and StateFlow with Swift interop (SKIE and Swift Export), Compose Multiplatform versus native SwiftUI, navigation, testing across two runtimes, dual-toolchain CI/CD with Linux and macOS runners, publishing a KMP library, and migrating an existing Android app module by module. Everything builds toward one capstone: an offline-first Expense/Budget Tracker, complete with a pending-sync queue, retry/backoff, and a deployment checklist for both stores.

Originally published on Medium.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.