Building an Android App by Hand, One Layer at a Time
Every Android project I've worked on starts with hitting "Run" and waiting for Gradle to do its thing. Source turns into an .apk and I've never had to think about the steps in between. This post is me tracing that path backward: starting from the smallest possible unit of compiled Java, a single .class file with no build tool at all, and adding one layer at a time until it's an app Gradle and the Android Gradle Plugin would recognize.
The file trees along the way follow one convention:
Hello World, One .class File
└── HelloWorld.java
That's the entire project. Before packages, before Gradle, before the Android Gradle Plugin, the JDK alone is enough to compile and run Java:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
No package declaration, no imports beyond what's built in, no project structure. Just javac, the Java compiler, and java, the launcher that runs on the JVM.
javac HelloWorld.java
java HelloWorld
Hello, world!
javac reads HelloWorld.java and writes HelloWorld.class next to it. That's the whole job: turn source into JVM bytecode. No dependency resolution, no packaging, no manifest, nothing else touches the disk:
├── HelloWorld.java└── HelloWorld.class ← generated by javac
Peeking inside the .class file
That .class file is 427 bytes, and it isn't machine code the CPU can run directly, it's bytecode: a portable instruction set the JVM interprets (or -compiles) at runtime. You can see this in the first bytes of the file using :
$ xxd HelloWorld.class | head -3
00000000: cafe babe 0000 003d 001d 0a00 0200 0307 .......=........
00000010: 0004 0c00 0500 0601 0010 6a61 7661 2f6c ..........java/l
00000020: 616e 672f 4f62 6a65 6374 0100 063c 696e ang/Object...<in
ca fe ba be is the class file magic number. Every valid .class file on every JVM starts with those exact four bytes, it's how the class loader rejects garbage before parsing anything else.
Right after it, 00 00 00 3d is the minor and major version: 3d is 61 in decimal, which maps to Java 17. That single number is why a .class compiled by a newer JDK can throw UnsupportedClassVersionError on an older JVM, the runtime checks it before loading the rest of the file.
How java finds and runs it
When java HelloWorld runs, three things happen: it looks for HelloWorld.class on the classpath (the current directory, by default), loads it after validating that header, then finds and invokes public static void main(String[]). That exact method signature is the contract; it's the only entry point the JVM will call.
This works cleanly right now because HelloWorld has no package. That stops being true the moment it gets one.
Kotlin Needs a Jar Java Doesn't
Same idea, one file, no project structure, just written in Kotlin instead of Java, before Gradle enters the picture at all:
fun greet(name: String) {
println("Hello, $name!")
}
fun main() {
greet("world")
}
kotlinc, the Kotlin compiler, plays javac's role:
$ kotlinc HelloWorld.kt -d .
$ ls
HelloWorld.kt
HelloWorldKt.class
META-INF
Nothing here is named HelloWorld.class, because there's no top-level class in the source, greet and main are , and Kotlin has to put static methods somewhere the JVM understands.
It synthesizes a class named after the file plus Kt:
├── HelloWorld.kt├── HelloWorldKt.class ← generated by kotlinc, one class for the whole file└── META-INF/main.kotlin_module ← compiler bookkeeping, irrelevant with one file
Where it breaks
Running it the same way java HelloWorld worked at the start doesn't go as cleanly:
$ java HelloWorldKt
Exception in thread "main" java.lang.NoClassDefFoundError: kotlin/jvm/internal/Intrinsics
at HelloWorldKt.greet(HelloWorld.kt)
at HelloWorldKt.main(HelloWorld.kt:6)
at HelloWorldKt.main(HelloWorld.kt)
Caused by: java.lang.ClassNotFoundException: kotlin.jvm.internal.Intrinsics
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
... 3 more
The first section's whole point was that javac's output needed nothing beyond the JVM itself, no dependency resolution, nothing else touching disk. Kotlin's doesn't hold to that.
greet takes a non-null String, and Kotlin enforces that at runtime as well as compile time, not just with a type check that disappears after compilation. shows what actually got emitted:
$ javap -c HelloWorldKt.class
Compiled from "HelloWorld.kt"
public final class HelloWorldKt {
public static final void greet(java.lang.String);
Code:
0: aload_0
1: ldc #9 // String name
3: invokestatic #15 // Method kotlin/jvm/internal/Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V
6: new #17 // class java/lang/StringBuilder
...
34: invokevirtual #46 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
37: return
public static final void main();
Code:
0: ldc #50 // String world
2: invokestatic #52 // Method greet:(Ljava/lang/String;)V
5: return
public static void main(java.lang.String[]);
Code:
0: invokestatic #55 // Method main:()V
3: return
}
Instruction 3 in greet is the tell: Intrinsics.checkNotNullParameter, stitched into every public function with a non-null reference parameter.
That's also why there are two main methods. Kotlin's own main() takes no arguments, so the compiler generates the main(String[]) the JVM actually requires, the same contract from the first section, and has it call straight into the one Kotlin source wrote.
Intrinsics isn't part of the JDK the way java.lang.* is, and it isn't sitting on ., the default classpath, either, so the class loader that found HelloWorld.class at the start of this post has nowhere left to look. That's the NoClassDefFoundError above.
Finding the missing jar
Intrinsics lives in kotlin-stdlib.jar, a jar the compiler ships next to itself rather than folding into HelloWorldKt.class:
$ find "$KOTLIN_HOME/lib" -name "kotlin-stdlib.jar"
.../kotlin/2.2.10/lib/kotlin-stdlib.jar
Put it on the classpath and the exact same class file runs:
$ java -cp ".:$KOTLIN_HOME/lib/kotlin-stdlib.jar" HelloWorldKt
Hello, world!
-cp takes one argument, a list of places to look for classes, colon-separated on macOS/Linux (; on Windows).
Each entry is either a directory, searched as if its contents were unpacked .class files, or a jar, searched the same way without unpacking it: the JVM opens it as a zip and looks for an entry whose path matches the class name, kotlin/jvm/internal/Intrinsics.class inside kotlin-stdlib.jar for kotlin.jvm.internal.Intrinsics. . is HelloWorldKt.class's directory, still needed since that class isn't in the jar either.
Order matters when a name exists in more than one entry, the class loader takes the first match and stops looking, but there's no collision here, so it's not visible in this run.
HelloWorld.class was 427 bytes and needed nothing else on disk. HelloWorldKt.class is 1,250 bytes and needs the rest of a 1.7MB jar just to load, a cost every non-null parameter and string template carries, invisible until something tries to run the class without that jar in reach.
Whatever compiles and runs this for real has to remember to put kotlin-stdlib.jar on the classpath every time, the same way it has to remember src/main/java, coming up next. Gradle does it with a dependencies {} block. The Android Gradle Plugin, later in this post, does it without being asked at all.
Giving It a Package
Real code almost never sits in Java's default, unnamed package, it's usually something like com.example.app. Adding one to HelloWorld looks like a single line:
package com.example;
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
Leave the file exactly where it is and it still compiles without complaint:
$ javac HelloWorld.java
$ ls
HelloWorld.class HelloWorld.java
javac only cares about compiling the file you gave it, it doesn't check where that file happens to sit on disk. Running it is where things break, and both the old and new names fail:
$ java com.example.HelloWorld
Error: Could not find or load main class com.example.HelloWorld
Caused by: java.lang.ClassNotFoundException: com.example.HelloWorld
$ java HelloWorld
Error: Could not find or load main class HelloWorld
Caused by: java.lang.NoClassDefFoundError: com/example/HelloWorld (wrong name: HelloWorld)
The first failure is simple: java's default classpath is ., and a class named com.example.HelloWorld has to live at ./com/example/HelloWorld.class. It's still sitting at ./HelloWorld.class, so the class loader never finds it.
The class loader chain
"The class loader" is really a chain of them, not one thing.
A request for a class goes to the application class loader (the one that knows about your classpath), but before it looks anywhere itself it delegates up to its parent, the platform class loader, which delegates up to the bootstrap class loader, the one built into the JVM that owns core java.* classes.
Whichever loader furthest up the chain can satisfy the request wins; only when every loader, all the way back down to the application class loader, comes up empty does the JVM give up and throw ClassNotFoundException, exactly what happened above.
└── Bootstrap ClassLoader ← asked 1st, owns java.*, javax.* (built into the JVM)└── Platform ClassLoader ← asked 2nd, JDK platform modules└── Application ClassLoader ← asked 3rd, walks your -classpath└── ClassNotFoundException ← thrown only if all three come up empty
The second failure is the more interesting one. javac doesn't just note your package declaration in the source, it bakes the fully qualified name into the compiled bytecode itself. javap shows it directly:
$ javap HelloWorld.class
Compiled from "HelloWorld.java"
public class com.example.HelloWorld {
public com.example.HelloWorld();
public static void main(java.lang.String[]);
}
The file on disk is still named HelloWorld.class, but the class inside it now identifies itself as com.example.HelloWorld, that's also why the file grew from 427 bytes to 439, the fully qualified name has to live somewhere.
When the JVM opens a file expecting HelloWorld and finds com.example.HelloWorld inside, it refuses to load it: wrong name.
The fix is to move the source to where the package says it belongs, then compile from there:
$ mkdir -p com/example
$ mv HelloWorld.java com/example/HelloWorld.java
$ javac com/example/HelloWorld.java
$ java com.example.HelloWorld
Hello, world!
└── com/└── example/├── HelloWorld.java ← moved to match the package└── HelloWorld.class ← compiled here automatically
Package com.example now maps directly onto the directory com/example, which is exactly where the class loader was looking all along. This isn't a Java quirk you can configure around, it's how class loading works.
And it's the first real motivation for a build tool to exist: once a project has dozens of packages and hundreds of files, manually keeping every directory in sync with every package declaration, and remembering to invoke javac and java from the right place with the right paths, stops being something a human should do by hand.
Enter Gradle
Gradle is the answer to that last problem: keeping every directory in sync with every package, and remembering the right javac/java invocation, by hand.
A Gradle project describes what to build once, in a that reads like data, and Gradle works out which commands to run and in what order. The minimal version of that description is two files:
plugins {
id 'application'
}
application {
mainClass = 'com.example.HelloWorld'
}
rootProject.name = 'hello-gradle'
It isn't data, though it reads like it.
plugins and application are methods on the Project object, each called with a , and everything inside { } runs against whatever object that closure's delegate points at, not against Project itself, the mechanism the Groovy build script primer covers in more depth than this post needs to.
For application {} that object is JavaApplication, which the application plugin registers on Project the moment it's applied. The block only exists because the plugins {} above it ran first, the scope is created by applying the plugin, not declared ahead of time.
That's the mechanism behind a familiar Gradle complaint: no autocomplete inside a block, and no reliable way to tell what's callable there. In Groovy that answer is resolved dynamically at runtime by walking a delegate chain the IDE can't fully see through.
The Kotlin DSL, build.gradle.kts, later in this post, exists largely to close that gap: the same blocks become typed with an explicit receiver, so the compiler knows what's in scope instead of the IDE guessing at it.
The application plugin
The application plugin is what turns "compile some Java" into "compile, package, and run a program." Source is left exactly where stage 2 put it, com/example/HelloWorld.java:
$ ./gradlew build
> Task :compileJava NO-SOURCE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :startScripts
> Task :distTar
> Task :distZip
> Task :assemble
> Task :compileTestJava NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test NO-SOURCE
> Task :check UP-TO-DATE
> Task :build
BUILD SUCCESSFUL in 415ms
4 actionable tasks: 4 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
BUILD SUCCESSFUL, no error, and a jar gets produced at build/libs/hello-gradle.jar. But look closer: compileJava says NO-SOURCE, and the jar it built only contains META-INF/MANIFEST.MF, no class file.
Gradle's Java plugin doesn't scan the project for .java files, it looks specifically in src/main/java and nowhere else. Nothing was there, so it quietly built an empty jar instead of failing.
That's a quieter failure mode than javac/java's ClassNotFoundException, easy to miss unless you check what actually landed in build/.
Same lesson as giving HelloWorld a package, once removed: a name has to live at the path that names it. There it was the JVM's class loader enforcing the rule with an exception. Here it's Gradle enforcing it by convention, silently.
$ mkdir -p src/main/java/com/example
$ mv com/example/HelloWorld.java src/main/java/com/example/HelloWorld.java
$ ./gradlew build
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :jar
> Task :startScripts
> Task :distTar
> Task :distZip
> Task :assemble
> Task :compileTestJava NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test NO-SOURCE
> Task :check UP-TO-DATE
> Task :build
BUILD SUCCESSFUL in 905ms
5 actionable tasks: 5 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
compileJava actually ran this time, and the jar has the real thing in it:
├── src/main/java/com/example/│ └── HelloWorld.java ← moved to match Gradle's convention├── build/ ← generated by gradle build│ ├── classes/java/main/com/example/HelloWorld.class│ ├── libs/hello-gradle.jar│ └── distributions/hello-gradle.zip├── build.gradle└── settings.gradle
No mkdir -p, no manually invoking javac with the right file, no separately running java with the right fully-qualified name. src/main/java still maps onto the package the same way com/example always had to, Gradle just owns making sure the mapping holds instead of leaving it to whoever remembers to run the right commands.
Tasks, not commands
./gradlew run replaces the manual java com.example.HelloWorld:
$ ./gradlew run
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :run
Hello, world!
BUILD SUCCESSFUL in 469ms
2 actionable tasks: 1 executed, 1 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
compileJava is already UP-TO-DATE, nothing changed since the last build, so Gradle skips straight to running it. That's not specific to run; building again with no changes shows the same thing everywhere:
$ ./gradlew build
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :startScripts UP-TO-DATE
> Task :distTar UP-TO-DATE
> Task :distZip UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :compileTestJava NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test NO-SOURCE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE
BUILD SUCCESSFUL in 335ms
5 actionable tasks: 5 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
javac/java never had this. Every invocation redid the entire job regardless of what changed since the last one.
The other thing that output makes obvious: build isn't one step, it's a name for a chain of tasks, each depending on the last one's output. --dry-run prints that chain without executing it:
$ ./gradlew build --dry-run
:compileJava SKIPPED
:processResources SKIPPED
:classes SKIPPED
:jar SKIPPED
:startScripts SKIPPED
:distTar SKIPPED
:distZip SKIPPED
:assemble SKIPPED
:compileTestJava SKIPPED
:processTestResources SKIPPED
:testClasses SKIPPED
:test SKIPPED
:check SKIPPED
:build SKIPPED
BUILD SUCCESSFUL in 295ms
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
jar needs classes, classes needs compileJava, build needs both assemble and check to finish, and so on.
Ask for build and Gradle walks that graph, running whatever's stale and skipping whatever's already UP-TO-DATE. Ask for run instead and it walks a shorter path through the same graph, compileJava and classes, then the task that actually invokes main().
Every one of these examples used build.gradle, Gradle's original Groovy DSL. Most Android projects generated today ship build.gradle.kts instead, the same file written in Kotlin.
It's not a different build tool or a different model, though: build.gradle.kts resolves to the identical task graph, same plugins, same application {} block, just statically typed Kotlin instead of dynamically typed Groovy. That's what actually motivated the switch: real autocomplete and compile-time errors in the IDE for a build script, instead of finding out about a typo at runtime.
Same concept, resolved differently
Plain Gradle with the application plugin gets a runnable JVM program out of this, not an Android app. There's no AndroidManifest.xml, no resource compilation, no .dex, no .apk, and no com.android.* in sight. That's what the Android Gradle Plugin adds next, more tasks bolted onto this same graph, not a different way of building.
The Android Gradle Plugin
From application to android
Turning the hello-gradle project into something recognizably Android starts with what looks like a one-line change:
plugins {
id 'com.android.application' version '9.3.0'
}
android {
namespace = 'com.example'
compileSdk = 35
defaultConfig {
applicationId = "com.example.hellogradle"
minSdk = 24
targetSdk = 35
}
}
The application {} block is gone; android {} takes over. namespace is what MainActivity will compile into and what the generated R class gets stamped with, compileSdk picks which Android API surface the code compiles against, minSdk/targetSdk bound which devices can install it and which platform behavior it opts into.
None of that touches javac directly, it's data the Android Gradle Plugin (AGP) reads to decide which tasks belong in the graph and how to configure them.
AGP ties its own version to a minimum Gradle version: 9.3.0 needs Gradle 9.5.0 or newer, which is why the wrapper from the "Enter Gradle" section is pinned to 9.7.1 rather than something older.
The plugin id swap isn't the whole diff, though.
com.android.application isn't published on Maven Central or the Gradle Plugin Portal, it lives on Google's own Maven repository, and the plain-Gradle settings.gradle has no idea that repository exists:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'hello-gradle'
The missing manifest
Run ./gradlew assembleDebug against just that much, before anything Android-specific exists on disk, and it fails, loudly:
$ ./gradlew assembleDebug
> Task :preBuild UP-TO-DATE
> Task :preDebugBuild UP-TO-DATE
> Task :mergeDebugNativeDebugMetadata NO-SOURCE
> Task :generateDebugResources
> Task :packageDebugResources
> Task :processDebugNavigationResources
> Task :generateDebugAssets UP-TO-DATE
> Task :javaPreCompileDebug
> Task :mergeDebugAssets
> Task :compressDebugAssets
> Task :parseDebugLocalResources
> Task :generateDebugGlobalSynthetics
> Task :desugarDebugFileDependencies
> Task :checkDebugDuplicateClasses
> Task :checkDebugAarMetadata
> Task :generateDebugRFile
> Task :mapDebugSourceSetPaths
> Task :compileDebugKotlin NO-SOURCE
> Task :mergeLibDexDebug
> Task :mergeExtDexDebug
> Task :compileDebugJavaWithJavac
> Task :processDebugJavaRes NO-SOURCE
> Task :compileDebugNavigationResources
> Task :mergeDebugResources
> Task :createDebugCompatibleScreenManifests
Manifest file does not exist: .../hello-gradle/src/main/AndroidManifest.xml
> Task :extractDeepLinksDebug
> Task :processDebugMainManifest FAILED
> Task :mergeDebugJavaResource
[Incubating] Problems report is available at: file://.../hello-gradle/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
A problem was found with the configuration of task ':processDebugMainManifest' (type 'ProcessApplicationManifest').
Input file does not exist
In plugin 'com.android.internal.version-check' type 'com.android.build.gradle.tasks.ProcessApplicationManifest' property 'mainManifest' specifies file '.../hello-gradle/src/main/AndroidManifest.xml' which doesn't exist
An input file was expected to be present but it doesn't exist
For more information, please refer to https://docs.gradle.org/9.7.1/userguide/validation_problems.html#input_file_does_not_exist in the Gradle documentation.
Possible solutions:
1. Make sure the file exists before the task is called.
2. Make sure that the task which produces the file is declared as an input.
* Try:
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
BUILD FAILED in 4s
22 actionable tasks: 22 executed
That's the opposite of the Java plugin's missing-source behavior from the last section. An empty src/main/java got a silent, empty jar. A missing AndroidManifest.xml gets a hard, named, task-level FAILED. AGP treats the manifest as load-bearing in a way the Java plugin never treated .java files.
Notice, too, that most of the graph runs before the failure: compileDebugJavaWithJavac, dexing, resource merging all complete. Gradle schedules by dependency order, not by risk; the manifest-dependent tasks just happen to sort after most of the others, so this build gets further than it looks like it should before failing.
Adding just enough Android
The fix is the same shape as every fix so far in this post: put the file where the convention says it goes.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:label="hello-gradle">
<activity
android:name="com.example.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
package com.example;
import android.app.Activity;
import android.os.Bundle;
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
}
├── src/main/│ ├── AndroidManifest.xml ← declares MainActivity as the launcher│ └── java/com/example/│ └── MainActivity.java ← replaces HelloWorld.java├── build.gradle└── settings.gradle
No res/ directory, no layout, no UI at all, just enough for AGP to have a launcher activity to point at. ./gradlew assembleDebug now runs the full pipeline:
$ ./gradlew assembleDebug
> Task :preBuild UP-TO-DATE
> Task :preDebugBuild UP-TO-DATE
> Task :mergeDebugNativeDebugMetadata NO-SOURCE
> Task :generateDebugResources UP-TO-DATE
> Task :packageDebugResources UP-TO-DATE
> Task :processDebugNavigationResources UP-TO-DATE
> Task :parseDebugLocalResources UP-TO-DATE
> Task :generateDebugRFile UP-TO-DATE
> Task :compileDebugKotlin NO-SOURCE
> Task :javaPreCompileDebug UP-TO-DATE
> Task :compileDebugJavaWithJavac
> Task :generateDebugAssets UP-TO-DATE
> Task :mergeDebugAssets UP-TO-DATE
> Task :compressDebugAssets UP-TO-DATE
> Task :generateDebugGlobalSynthetics UP-TO-DATE
> Task :processDebugJavaRes NO-SOURCE
> Task :mergeDebugJavaResource UP-TO-DATE
> Task :checkDebugDuplicateClasses UP-TO-DATE
> Task :desugarDebugFileDependencies UP-TO-DATE
> Task :mergeExtDexDebug UP-TO-DATE
> Task :mergeLibDexDebug UP-TO-DATE
> Task :checkDebugAarMetadata UP-TO-DATE
> Task :mapDebugSourceSetPaths UP-TO-DATE
> Task :compileDebugNavigationResources UP-TO-DATE
> Task :mergeDebugResources UP-TO-DATE
> Task :createDebugCompatibleScreenManifests UP-TO-DATE
> Task :extractDeepLinksDebug UP-TO-DATE
> Task :processDebugMainManifest
> Task :processDebugManifest
> Task :mergeDebugJniLibFolders
> Task :mergeDebugNativeLibs NO-SOURCE
> Task :stripDebugDebugSymbols NO-SOURCE
> Task :validateSigningDebug
> Task :writeDebugAppMetadata
> Task :writeDebugSigningConfigVersions
> Task :processDebugManifestForPackage
> Task :processDebugResources
> Task :dexBuilderDebug
> Task :mergeProjectDexDebug
> Task :packageDebug
> Task :createDebugApkListingFileRedirect
> Task :assembleDebug
BUILD SUCCESSFUL in 1s
33 actionable tasks: 13 executed, 20 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
33 actionable tasks for a program with one empty Activity and zero resources, against 5 for the plain JVM version of the same project (20 of those tasks were already cached as UP-TO-DATE from the failed attempt above, the caching from the last section doesn't care whether the previous run succeeded).
Two of those, compileDebugKotlin and compileDebugNavigationResources/processDebugNavigationResources, run even though this project has no Kotlin source and no navigation graph; the next section covers why.
The rest is the shape you'd expect: manifest merging, resource parsing, dex building, native lib merging, signing validation, none of that existed in the Java plugin's graph, and none of it was optional.
That's "more tasks bolted onto this same graph" from the end of the last section, made concrete: compileDebugJavaWithJavac sitting in the middle of that list is doing exactly what compileJava did before, javac turning .java into .class. It's just one step now instead of the last one. Everything before it exists to feed it the right inputs; everything after it exists to turn its output into something a device can install.
Build it again with nothing changed and the caching behaves exactly like it did for the plain JVM project:
$ ./gradlew assembleDebug
...
BUILD SUCCESSFUL in 387ms
33 actionable tasks: 33 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
An uninvited dependency
com.android.application 9.3.0 was the only line that changed in build.gradle. No kotlin plugin was applied, no dependencies {} block was written. And yet:
$ ./gradlew dependencies --configuration debugRuntimeClasspath
debugRuntimeClasspath - Runtime classpath of '/debug'.
\--- org.jetbrains.kotlin:kotlin-stdlib:2.2.10
\--- org.jetbrains:annotations:13.0
Since AGP 9.0, Kotlin support is built into the plugin and turned on by default: every com.android.application module gets a runtime dependency on kotlin-stdlib whether or not it has a single .kt file, because AGP can no longer tell in advance that it won't. That's the same jar java HelloWorldKt couldn't find on its own back near the start of this post, added to the classpath automatically instead of by hand.
compileDebugKotlin NO-SOURCE in the task list above is that built-in Kotlin compilation step running and immediately finding nothing to do, the same NO-SOURCE pattern compileTestJava showed back in "Enter Gradle," just for a compiler this project never asked for.
Same lesson as namespace generating an R class whether or not the project declares resources: the plugin now provisions for a capability unconditionally rather than only when the source tree asks for it. The next section shows what that costs in the shipped apk.
From Build to .apk
Unzipping the apk
assembleDebug leaves an 872,018-byte file at build/outputs/apk/debug/hello-gradle-debug.apk. An .apk is a zip, so the fastest way to see what actually got packaged is to list it like one:
$ unzip -l hello-gradle-debug.apk
Length Date Time Name
--------- ---------- ----- ----
56 01-01-1981 01:01 META-INF/com/android/build/gradle/app-metadata.properties
2420316 01-01-1981 01:01 classes.dex
552 01-01-1981 01:01 classes2.dex
776 01-01-1981 01:01 classes3.dex
113660 01-01-1981 01:01 classes4.dex
1844 01-01-1981 01:01 AndroidManifest.xml
40 01-01-1981 01:01 resources.arsc
1006 01-01-1981 01:01 kotlin/annotation/annotation.kotlin_builtins
8308 01-01-1981 01:01 kotlin/collections/collections.kotlin_builtins
2674 01-01-1981 01:01 kotlin/concurrent/atomics/atomics.kotlin_builtins
137 01-01-1981 01:01 kotlin/coroutines/coroutines.kotlin_builtins
590 01-01-1981 01:01 kotlin/internal/internal.kotlin_builtins
29399 01-01-1981 01:01 kotlin/kotlin.kotlin_builtins
4184 01-01-1981 01:01 kotlin/ranges/ranges.kotlin_builtins
4827 01-01-1981 01:01 kotlin/reflect/reflect.kotlin_builtins
--------- -------
2588369 15 files
The entries sum to 2,588,369 bytes, three times the 872,018-byte apk on disk: DEFLATE is doing real work on compressible bytecode here, not just padding a handful of tiny files.
The dex files nobody asked for
Four .dex files and eight kotlin/*.kotlin_builtins metadata files, for a project with zero Kotlin source. Both trace back to the same cause as the previous section: AGP 9.3.0 pulls in kotlin-stdlib unconditionally. The .kotlin_builtins files are that library's compiler metadata, packaged into the apk like any other library resource.
classes2.dex (552 bytes) and classes3.dex (776 bytes) are the project's own two classes, R and MainActivity. classes.dex (2.4MB) and classes4.dex (113KB) are the interesting ones.
classes.dex turns out to be exactly what it looks like. is the closest thing to javap for this format:
$ unzip -p hello-gradle-debug.apk classes.dex > classes.dex
$ dexdump -d classes.dex | grep -c "Class descriptor"
1090
$ dexdump -d classes.dex | grep "Class descriptor" | head -3
Class descriptor : 'Lkotlin/ArrayIntrinsicsKt;'
Class descriptor : 'Lkotlin/BuilderInference;'
Class descriptor : 'Lkotlin/CharCodeJVMKt;'
1,090 classes, every one of them kotlin.*. Not a stub, not metadata: the actual kotlin-stdlib:2.2.10 jar, dexed and merged into the apk by mergeExtDexDebug the same way any real dependency would be. A project that never imports kotlin.* ships the whole standard library anyway, because the dependency was never a choice the source tree made.
classes4.dex is stranger. It's 721 classes, and none of them are called anywhere in MainActivity:
$ unzip -p hello-gradle-debug.apk classes4.dex > classes4.dex
$ dexdump -d classes4.dex | grep -c "Class descriptor"
721
$ dexdump -d classes4.dex | grep "Class descriptor" | head -3
Class descriptor : 'Landroid/accessibilityservice/AccessibilityButtonController$AccessibilityButtonCallback;'
Class descriptor : 'Landroid/accessibilityservice/AccessibilityService$TakeScreenshotCallback;'
Class descriptor : 'Landroid/accessibilityservice/BrailleDisplayController$BrailleDisplayCallback;'
Disassembling one shows why they're there at all:
$ dexdump -d classes4.dex
Class #0 -
Class descriptor : 'Landroid/accessibilityservice/BrailleDisplayController;'
Access flags : 0x1601 (PUBLIC INTERFACE ABSTRACT SYNTHETIC)
Superclass : 'Ljava/lang/Object;'
Direct methods -
#0 : <clinit>()V
009368: 2200 0003 |new-instance v0, Ljava/lang/NoClassDefFoundError;
00936c: 7010 1f03 0000 |invoke-direct {v0}, NoClassDefFoundError.<init>:()V
009372: 2700 |throw v0
All 721 are the same shape: a PUBLIC INTERFACE ABSTRACT SYNTHETIC type whose entire body throws NoClassDefFoundError the instant it's touched. That's D8's fallback when it needs to resolve an android.* or dalvik.* interface's type hierarchy for desugaring (minSdk 24 still needs Java 8 interface default/static methods backported) but can't find the real definition on the classpath it was given. Rather than fail the build, it synthesizes a placeholder that throws if the app ever actually calls it.
The list isn't limited to one API area either: alongside the accessibility-service classes it includes android/window/OnBackInvokedCallback and even R8's own internal marker type com/android/tools/r8/RecordTag, consistent with D8 walking kotlin-stdlib's own type references during desugaring, not anything specific to this project's two classes.
Dead code, verifiably dead (nothing in MainActivity's two methods calls into android.* beyond Activity/Bundle), but it's real, reproducible bytes in the shipped apk: 113,660 of them.
The class that's actually yours
R and MainActivity themselves are untouched by any of this. Pulling classes3.dex and disassembling it answers the same question xxd answered for HelloWorld.class back at the start:
$ unzip -p hello-gradle-debug.apk classes3.dex > classes3.dex
$ dexdump -d classes3.dex
Class #0 -
Class descriptor : 'Lcom/example/MainActivity;'
Superclass : 'Landroid/app/Activity;'
Direct methods -
#0 : <init>()V
000114: 7010 0000 0000 |invoke-direct {v0}, Landroid/app/Activity;.<init>:()V
00011a: 0e00 |return-void
Virtual methods -
#0 : onCreate(Landroid/os/Bundle;)V
00012c: 6f20 0100 1000 |invoke-super {v0, v1}, Landroid/app/Activity;.onCreate:(Landroid/os/Bundle;)V
000132: 0e00 |return-void
source_file_idx : 4 (MainActivity.java)
Same shape of answer javap gave for HelloWorld.class: the class's declared name, its superclass, its methods, as Dalvik instructions (invoke-direct, invoke-super, return-void) instead of JVM bytecode. onCreate doing nothing but call super.onCreate() is right there in the disassembly, because that's genuinely all it does.
The dex format version, dex\n037\0, plays the same role ca fe ba be did for a .class file: checked before anything else in the file is trusted, then handed to ART rather than the JVM class loader chain. Whatever's happening with kotlin-stdlib and those 721 stub classes, this project's own two classes are exactly what the source says they are.
The manifest that shipped vs. the manifest that was written
The manifest is a different story, no friendly ASCII magic string the way .class and .dex have, just a binary chunk header. aapt2, the same tool AGP uses internally to compile it, can walk that structure directly from the apk:
$ aapt2 dump xmltree hello-gradle-debug.apk --file AndroidManifest.xml
N: android=http://schemas.android.com/apk/res/android (line=2)
E: manifest (line=2)
A: http://schemas.android.com/apk/res/android:compileSdkVersion(0x01010572)=35
A: http://schemas.android.com/apk/res/android:compileSdkVersionCodename(0x01010573)="15" (Raw: "15")
A: package="com.example.hellogradle" (Raw: "com.example.hellogradle")
A: platformBuildVersionCode=35
A: platformBuildVersionName=15
E: uses-sdk (line=5)
A: http://schemas.android.com/apk/res/android:minSdkVersion(0x0101020c)=24
A: http://schemas.android.com/apk/res/android:targetSdkVersion(0x01010270)=35
E: application (line=9)
A: http://schemas.android.com/apk/res/android:label(0x01010001)="hello-gradle" (Raw: "hello-gradle")
A: http://schemas.android.com/apk/res/android:debuggable(0x0101000f)=true
A: http://schemas.android.com/apk/res/android:extractNativeLibs(0x010104ea)=false
E: activity (line=13)
A: http://schemas.android.com/apk/res/android:name(0x01010003)="com.example.MainActivity" (Raw: "com.example.MainActivity")
A: http://schemas.android.com/apk/res/android:exported(0x01010010)=true
None of compileSdkVersion, package, platformBuildVersionCode, platformBuildVersionName, uses-sdk, debuggable, or extractNativeLibs were written in src/main/AndroidManifest.xml. The manifest that shipped isn't the manifest that was authored, AGP's manifest merger injects every one of those from android {} in build.gradle: compileSdk, minSdk, targetSdk, applicationId becoming package, the debug build type contributing debuggable, during processDebugMainManifest, one of the tasks in that 33-task list.
Same pattern as build.gradle.kts resolving to the identical task graph as build.gradle: the file on disk is a source of truth Gradle reads from and rewrites, never quite the thing that ships as-is.
Signed without a signing config
The last piece is why the only META-INF/ entry in that listing is AGP's own app-metadata.properties, no CERT.SF, no .RSA, despite the apk clearly being signed. apksigner confirms it's signed:
$ apksigner verify --print-certs hello-gradle-debug.apk
Signer #1 certificate DN: C=US, O=Android, CN=Android Debug
Signer #1 certificate SHA-256 digest: fb77020bd967e845632f2af0286a09ef7b18dc725bb5567b5260e9f9a5edcadf
Signed with the well-known Android debug certificate, CN=Android Debug, no signing config anywhere in build.gradle. CN=Android Debug is a fixed convention name every debug keystore uses: the actual key is generated locally the first time validateSigningDebug needs one, so this fingerprint is specific to the machine this apk was built on; a different machine building the same source gets the same CN, a different key underneath it.
Per-file META-INF signatures are how the older v1, JAR-based signing scheme works; this apk uses the newer v2/v3 APK Signing Block instead, which signs the whole file as one unit appended after the zip's central directory rather than entry by entry, so it never shows up in unzip -l at all.
validateSigningDebug, another task from that same list, is what generates and applies a debug keystore automatically the first time one's needed, enforced by convention the same way src/main/java and AndroidManifest.xml were.
That's the whole path this post set out to trace: a .class file with no package, then one with a package it had to live up to, a plain Gradle project turning that into a .jar it could compile, cache, and run on demand, and now an AGP project turning the same source into a signed .apk, with a merged manifest, a resource table, and Dalvik bytecode a .jar never had to think about.
Every layer added was solving a real problem the layer before it couldn't. Run in Android Studio hides all of it behind one button. Underneath, it's still javac, d8, aapt2, and apksigner, wired together by a task graph, run in that order, every time.
Summary
Six layers, each solving a problem the last one couldn't:
- .class file. Just javac and java. Holds only as long as there's no package, once there is, the fully qualified name baked into the bytecode has to match where the file sits on disk.
- Kotlin's .class file. Same JVM bytecode, but Intrinsics.checkNotNullParameter calls into kotlin-stdlib.jar, a runtime that isn't part of the JDK and has to be put on the classpath by hand.
- Gradle. Stops that bookkeeping from being manual: src/main/java becomes the one convention that matters, and build/run walk a task graph instead of a remembered javac/java invocation, with UP-TO-DATE caching skipping whatever hasn't changed.
- Android Gradle Plugin. Bolts more tasks onto the same graph. android {} instead of application {}, AndroidManifest.xml as load-bearing as src/main/java, and since AGP 9.0 a kotlin-stdlib dependency applied whether or not the project has a line of Kotlin.
- The shipped .dex/.kotlin_builtins. The cost of that unconditional Kotlin dependency: extra .dex files and kotlin.kotlin_builtins entries in every apk, Kotlin or not.
- The .apk. A zip with a manifest merged from android {} that's never quite the one written by hand, R and MainActivity exactly as declared, and a debug certificate applied automatically the first time validateSigningDebug needs one.
Run in Android Studio hides all of it behind one button. Underneath, in order: javac or kotlinc, d8, aapt2, apksigner, wired together by a task graph that knows the dependency order so nobody has to.