Lesson 6.5: Kotlin in bytecode
Open a recent Android app in JADX and you almost certainly see Java. Aren’t most apps written in Kotlin these days? Yes, but Kotlin compiles to the same DEX bytecode as Java, so the decompiler reads the bytecode and rebuilds it as Java, the only language it knows how to output. The original Kotlin is gone.
The Kotlin compiler does leave a lot of characteristic traces. Once you recognize them, you know the code was Kotlin and you can read the intent faster instead of wading through machine-generated Java. This lesson is a list of those traces.
@Metadata
Every class generated by Kotlin carries an @Metadata annotation at the top. In JADX you’ll see something like:
1
2
@Metadata(mv = {1, 9, 0}, k = 1, d1 = {"..."}, d2 = {"..."})
public final class LoginManager {
d1/d2 are the encoded Kotlin signature (function names, parameter types, nullability, original parameter names). If you see @Metadata, the class was Kotlin. Also, some tools (like jadx with a plugin, or kotlin-metadata readers) use d2 to recover the real parameter names, which Java bytecode usually doesn’t keep.
Intrinsics null-checks everywhere
Kotlin distinguishes nullable types (String?) from non-null (String). To keep the non-null promise at the boundary with Java, the compiler inserts automatic checks at the start of almost every function:
1
2
3
4
5
public final void login(String user, String pass) {
Intrinsics.checkNotNullParameter(user, "user");
Intrinsics.checkNotNullParameter(pass, "pass");
// ... the real code starts here
}
Those Intrinsics.checkNotNullParameter lines (and checkNotNull, checkNotNullExpressionValue) aren’t the author’s logic, they’re compiler-generated. Skip them and jump down to the real code. Seeing Intrinsics.* everywhere is one more confirmation that this is Kotlin.
Data classes
In Kotlin you write one line:
1
data class User(val id: Int, val name: String)
But the compiler generates a dozen methods, and JADX shows them all:
1
2
3
4
5
6
7
8
9
10
public final class User {
private final int id;
private final String name;
public final int component1() { return this.id; }
public final String component2() { return this.name; }
public final User copy(int id, String name) { ... }
public boolean equals(Object other) { ... } // compares field by field
public int hashCode() { ... }
public String toString() { return "User(id=" + id + ", name=" + name + ")"; }
}
component1(), component2(), copy(), plus equals/hashCode/toString comparing and printing per field is the signature of a data class. It’s just a chunk of data with no logic worth reading, so skim past it.
Companion objects and extension functions
A companion object (where Kotlin keeps a class’s static members) turns into an inner class named Companion with a static field:
1
public static final User.Companion Companion = new User.Companion(null);
An extension function (fun String.clean()) has no equivalent in the JVM, so it becomes a static method that takes the receiver as the first parameter:
1
2
// Kotlin: fun String.clean(): String
public static final String clean(String $this$clean) { ... }
A parameter named $this$... means an extension function.
Coroutines become a state machine
This is the part that makes Kotlin pseudocode messiest. A suspend fun looks linear in the source:
1
2
3
4
5
suspend fun fetch(): Data {
val token = getToken() // suspend
val data = download(token) // suspend
return data
}
After compilation, the compiler tears it into a state machine. The whole function body goes into a function that takes an extra Continuation parameter, with a label variable and a big switch. Each suspension point is a case. In JADX you see something like:
1
2
3
4
5
6
7
8
9
10
11
12
13
public final Object fetch(Continuation $completion) {
// ... restore state from $continuation.label
switch (this.label) {
case 0:
this.label = 1;
obj = getToken(this); // if it returns COROUTINE_SUSPENDED, return right away
if (obj == suspended) return suspended;
break;
case 1:
// continue after getToken, now call download
...
}
}
Don’t try to understand the state machine mechanism. Treat label as a step number, and read each case in increasing order, which are the sequential lines in the original source. Each case does one thing then sets label to the next step. Put them together and you get the original linear logic. The == suspended returns are only the pause/resume mechanism, which you can ignore while tracing the logic.
Key takeaways
Kotlin compiles to the same bytecode as Java, so JADX shows Java and the original Kotlin is gone. @Metadata at the top of a class means it’s Kotlin, and it holds the signature for recovering names. Intrinsics.checkNotNull* is compiler-inserted, so skip it when reading.
A data class shows up as component1/2..., copy, and equals/hashCode/toString per field. An extension function becomes a static method with a $this$... parameter. Coroutines become a state machine with label + switch, so read each case in order like the sequential steps of the source.
Lab
The goal is to take an Android app written in Kotlin, open it in JADX-GUI, and find the traces the Kotlin compiler leaves behind. You don’t need a real device, just an APK file. Choose any APK you’re sure is written in Kotlin. Legitimate sources include open-source apps on F-Droid (most are Kotlin; download the APK directly), a Kotlin demo app you build yourself with Android Studio, or your own app. Don’t use a commercial app you have no right to analyze.
Open the APK in JADX-GUI. Pick any class in the app’s main package and confirm it has an @Metadata annotation at the top, which shows the class was originally Kotlin. In a few methods, look for lines like Intrinsics.checkNotNullParameter or Intrinsics.checkNotNull..., and remember these are compiler noise with the real logic behind them. Then find a data class, which is a class with the full set of component1(), component2(), copy(...) and field-based equals/hashCode/toString, and write down its name and fields. Find a companion object, which shows up as a static field named Companion of type ClassName.Companion. As an advanced step, find a suspend fun (a coroutine). It’s a method taking a parameter of type Continuation, with a label variable and a switch inside. Read each case in increasing order and rewrite its original sequential logic.
Two questions to think about. Why doesn’t JADX give you back the original Kotlin code even though the app was written in Kotlin? And if you want to recover real parameter names instead of p0, p1, which information can you use? Try it yourself first, then open the solution.
Show solution
Almost every Kotlin class appears in JADX like this:
1
2
@Metadata(mv = {1, 9, 0}, k = 1, xi = 48, d1 = {"\u0000..."}, d2 = {"Lcom/app/LoginManager;", "", "()V", "login", "", "user", "", "pass"})
public final class LoginManager {
k = 1 means a class, d1 is an encoded string table, and d2 lists signatures and names. @Metadata is enough to know the class was originally Kotlin, since a class compiled from pure Java has no such annotation.
At the top of public functions you often see:
1
2
Intrinsics.checkNotNullParameter(user, "user");
Intrinsics.checkNotNullParameter(pass, "pass");
This is code the compiler inserts to enforce Kotlin’s non-null constraints. When reading the logic, skip the Intrinsics.* lines and start from the line right after them. They also leak the original parameter names ("user", "pass") as strings, which helps when the bytecode has lost its variable names.
A typical data class looks like this:
1
2
3
4
5
6
7
8
9
10
public final class User {
private final int id;
private final String name;
public final int component1() { return this.id; }
public final String component2() { return this.name; }
public final User copy(int id, String name) { return new User(id, name); }
public boolean equals(Object o) { /* compares id and name */ }
public int hashCode() { /* mixes id and name */ }
public String toString() { return "User(id=" + this.id + ", name=" + this.name + ")"; }
}
The set of componentN plus copy plus field-based equals/hashCode/toString is easy to recognize. It’s only a data holder and contains no logic worth reading.
A companion object shows up in Java as:
1
2
public static final User.Companion Companion = new User.Companion(null);
public static final class Companion { /* the class's static functions and consts */ }
Every member you declared in Kotlin’s companion object ends up in this inner Companion class.
A suspend fun fetch() that calls two suspend functions becomes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final Object fetch(Continuation $completion) {
ContinuationImpl cont; // restore/save state
switch (cont.label) {
case 0:
cont.label = 1;
Object t = getToken(cont);
if (t == IntrinsicsKt.getCOROUTINE_SUSPENDED()) return t;
// fall through and use t
case 1:
cont.label = 2;
Object d = download(token, cont);
if (d == IntrinsicsKt.getCOROUTINE_SUSPENDED()) return d;
return d;
}
}
Treat label as the step number. case 0 is step 1 (call getToken) and case 1 is step 2 (call download). Chained in order they give the linear logic:
1
2
3
token = getToken()
data = download(token)
return data
The == COROUTINE_SUSPENDED ... return lines are just the suspension mechanism while waiting, not business logic, so ignore them when working out the intent.
On the questions, JADX reads DEX bytecode, and Kotlin compiles to the same kind of bytecode as Java, so the decompiler can only rebuild Java. The original Kotlin syntax (coroutines, data classes, extensions) was already lowered into JVM constructs before it reached the bytecode. To recover real parameter names, use the strings in @Metadata (d2) and the name strings in Intrinsics.checkNotNullParameter(x, "originalName").
