Java Interop, Legacy Code & Kotlin Migration Strategy
Bridge two worlds — master Java-Kotlin interop and lead migration of real-world codebases confidently
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Java-Kotlin interop is the set of annotations, conventions, and compiler behaviors that allow Java and Kotlin code to coexist and call each other within the same project. Kotlin was designed for 100% Java interop — they compile to the same JVM bytecode and can freely reference each other's classes. However, differences in null safety, default parameters, static members, and functional interfaces create friction points that senior developers must understand. A migration strategy is the systematic approach to converting a Java codebase to Kotlin incrementally without breaking the running application.
Real-world relevance
You join a company with a 400K-line Java Android app built over 5 years. New features are in Kotlin, but 70% of the codebase is still Java. Your ViewModel (Kotlin) calls a Java repository that returns User objects without nullability annotations — platform types everywhere. You add @NonNull/@Nullable annotations to the Java interfaces, use @JvmOverloads on your Kotlin factory functions so Java fragments can call them, and plan a module-by-module migration starting with the :data:models module. When merging upstream library updates, you resolve conflicts between their Java changes and your Kotlin conversions.
Key points
- Why Java Knowledge Still Matters — The Android SDK itself is written in Java. Framework source code, stack traces, and many third-party libraries are Java. Legacy enterprise apps have millions of lines of Java. Companies like Murena and many US Android teams maintain Java codebases. Even in a pure-Kotlin project, you'll read Java code daily — in framework internals, decompiled bytecode, and library sources. Senior developers MUST be bilingual.
- Platform Types — Kotlin's Unknown Nullability — When Kotlin calls Java code that lacks nullability annotations, the return type becomes a 'platform type' shown as Type! in the IDE. Platform types bypass Kotlin's null safety — the compiler won't force null checks. This means a Java method returning String (without @NonNull) becomes String! in Kotlin, and accessing it can throw NPE at runtime. Always add explicit type declarations when storing Java results: val name: String? = javaObj.getName()
- Nullability Annotations — The Bridge — @Nullable and @NonNull (from javax.annotation, androidx.annotation, org.jetbrains.annotations, or JSpecify) tell Kotlin's compiler about Java's null intentions. @NonNull String getName() becomes String in Kotlin (non-nullable). @Nullable String getName() becomes String? in Kotlin. Adding these annotations to Java code before migration dramatically improves interop safety and makes eventual Kotlin conversion cleaner.
- @JvmStatic — Exposing Companion Members to Java — Kotlin companion object functions are accessed from Java as MyClass.Companion.myMethod(). Adding @JvmStatic generates a real static method on the class, so Java can call MyClass.myMethod() directly. Essential when Java code calls Kotlin factories, constants, or utility functions. Without @JvmStatic, Java callers must know about the Companion object pattern.
- @JvmField and @JvmOverloads — @JvmField exposes a Kotlin property as a direct Java field (no getter/setter). Used for constants: @JvmField val MAX_RETRY = 3 → accessible as MyClass.MAX_RETRY from Java. @JvmOverloads generates Java overloaded methods for Kotlin functions with default parameters. Without it, Java must pass ALL parameters because Java doesn't support default values.
- SAM Conversions — Java Functional Interfaces in Kotlin — Kotlin can convert a lambda to a Java functional interface (Single Abstract Method) automatically. setOnClickListener { view -> handleClick(view) } works because View.OnClickListener is a SAM interface. However, Kotlin interfaces are NOT SAM-compatible — you must use 'fun interface' keyword for Kotlin interfaces to support SAM conversion. This distinction trips up senior developers in interviews.
- Interop Gotchas — Default Parameters, Sealed Classes, Companions — Java cannot use Kotlin default parameters without @JvmOverloads. Java sees Kotlin companion objects as a static inner class called Companion. Kotlin sealed classes compile to abstract classes with private constructors — Java can't add new subclasses (enforced at compile time since Kotlin 1.7). Kotlin's 'internal' visibility compiles to public with a mangled name in bytecode — Java can technically access it.
- Migration Strategy — Module by Module — Don't convert an entire app at once. Strategy: (1) Add Kotlin support to the Gradle build, (2) Write all NEW code in Kotlin, (3) Add nullability annotations to Java code at interop boundaries, (4) Convert leaf modules first (utilities, models, data layer) because they have fewer dependents, (5) Convert feature modules next, (6) Convert the app module last. Each module should pass all tests before moving to the next.
- Android Studio Java-to-Kotlin Converter — Code → Convert Java File to Kotlin File (Ctrl+Alt+Shift+K). The converter handles ~80% correctly but commonly produces issues: platform types without explicit nullability, unnecessary !! operators, non-idiomatic patterns (e.g., keeping Java-style builders instead of using apply), and broken complex generics. ALWAYS review and fix the output. Run tests immediately after conversion.
- Handling Java Libraries in Kotlin — Gson with Kotlin data classes requires default values (Gson uses unsafe reflection that bypasses constructors). Prefer Moshi or kotlinx.serialization for Kotlin-first JSON. RxJava works well with Kotlin but consider migrating to Kotlin Coroutines + Flow for new code. Java Stream API is unnecessary — use Kotlin's collection operators instead (map, filter, fold). Retrofit works seamlessly with both languages.
- Testing Mixed Codebases — JUnit 4/5 works identically for both Java and Kotlin test classes. Mockito works with Kotlin but needs mockito-kotlin library for idiomatic usage (inline functions, reified types). MockK is the Kotlin-native alternative. In a mixed codebase, you can write Kotlin tests for Java code and vice versa. When migrating, convert test files AFTER their production counterparts to maintain test coverage continuity.
- Open Source Fork Workflow — Merge Upstream Changes — For companies maintaining AOSP forks or open-source projects (like Murena with /e/OS): (1) Fork the upstream repo, (2) Create feature branches for your changes, (3) Regularly fetch upstream: git fetch upstream, (4) Merge upstream into your main: git merge upstream/main, (5) Resolve conflicts — Kotlin-Java conflicts are common when upstream adds Java and you've converted to Kotlin, (6) Use 'git rerere' to remember conflict resolutions for repeated merges.
Code example
// ============================================
// JAVA CLASS WITH NULLABILITY ANNOTATIONS
// ============================================
// File: UserRepository.java
//
// import androidx.annotation.NonNull;
// import androidx.annotation.Nullable;
//
// public class UserRepository {
// // @NonNull → Kotlin sees this as String (non-nullable)
// @NonNull
// public String getUserName(int userId) {
// return database.queryName(userId); // Never returns null
// }
//
// // @Nullable → Kotlin sees this as String? (nullable)
// @Nullable
// public String getUserBio(int userId) {
// return database.queryBio(userId); // Might return null
// }
//
// // NO annotation → Kotlin sees this as String! (platform type!)
// public String getUserEmail(int userId) {
// return database.queryEmail(userId); // Unknown nullability
// }
// }
// ============================================
// KOTLIN CALLING JAVA — HANDLING PLATFORM TYPES
// ============================================
// Safe: compiler knows nullability from annotations
fun displayUser(repo: UserRepository, userId: Int) {
val name: String = repo.getUserName(userId) // Safe: @NonNull
val bio: String? = repo.getUserBio(userId) // Safe: @Nullable
// DANGEROUS: platform type String! — could be null at runtime!
// val email = repo.getUserEmail(userId) // DON'T do this
val email: String? = repo.getUserEmail(userId) // Safe: explicit type
println("Name: ${name}")
println("Bio: ${bio ?: "No bio provided"}")
println("Email: ${email ?: "No email"}")
}
// ============================================
// @JvmStatic, @JvmField, @JvmOverloads
// ============================================
class ApiConfig {
companion object {
// Without @JvmField: Java calls ApiConfig.Companion.getMAX_RETRIES()
// With @JvmField: Java calls ApiConfig.MAX_RETRIES
@JvmField
val MAX_RETRIES = 3
// Without @JvmStatic: Java calls ApiConfig.Companion.create()
// With @JvmStatic: Java calls ApiConfig.create()
@JvmStatic
fun create(): ApiConfig = ApiConfig()
}
}
// Without @JvmOverloads, Java must pass ALL parameters:
// new UserQuery("Alice", 0, 20, "name", true)
// With @JvmOverloads, Java gets overloaded constructors:
// new UserQuery("Alice")
// new UserQuery("Alice", 5)
// new UserQuery("Alice", 5, 50)
class UserQuery @JvmOverloads constructor(
val searchTerm: String,
val page: Int = 0,
val pageSize: Int = 20,
val sortBy: String = "name",
val ascending: Boolean = true
)
// ============================================
// SAM CONVERSION — Java interface in Kotlin
// ============================================
// Java interface (SAM — single abstract method):
// public interface OnItemClickListener {
// void onItemClick(Item item);
// }
// Kotlin usage — lambda auto-converts to SAM:
// adapter.setOnItemClickListener { item ->
// navigateToDetail(item.id)
// }
// Kotlin fun interface (supports SAM conversion):
fun interface Validator {
fun validate(input: String): Boolean
}
// Usage: val emailValidator = Validator { it.contains("@") }
// ============================================
// MIGRATION EXAMPLE: Java Activity → Kotlin
// ============================================
// BEFORE (Java):
// public class ProfileActivity extends AppCompatActivity {
// private TextView nameText;
// private ImageView avatar;
// private UserRepository repo;
//
// @Override
// protected void onCreate(Bundle savedInstanceState) {
// super.onCreate(savedInstanceState);
// setContentView(R.layout.activity_profile);
// nameText = findViewById(R.id.nameText);
// avatar = findViewById(R.id.avatar);
// repo = new UserRepository();
// loadProfile();
// }
//
// private void loadProfile() {
// String userId = getIntent().getStringExtra("USER_ID");
// if (userId != null) {
// User user = repo.getUser(userId);
// nameText.setText(user.getName());
// }
// }
// }
// AFTER (Kotlin — idiomatic, not just auto-converted):
class ProfileActivity : AppCompatActivity() {
private lateinit var binding: ActivityProfileBinding
private val viewModel: ProfileViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityProfileBinding.inflate(layoutInflater)
setContentView(binding.root)
val userId = intent.getStringExtra("USER_ID")
?: run { finish(); return }
viewModel.loadProfile(userId)
viewModel.user.observe(this) { user ->
binding.nameText.text = user.name
}
}
}
// ============================================
// GIT WORKFLOW: Merging Upstream Fork Changes
// ============================================
//
// # Initial setup (one time)
// git remote add upstream https://github.com/AospProject/platform_packages_apps.git
//
// # Regular sync workflow
// git fetch upstream
// git checkout main
// git merge upstream/main
//
// # When upstream adds Java files you've already converted to Kotlin:
// # 1. Accept upstream's Java changes
// # 2. Re-convert the changed Java file to Kotlin
// # 3. Apply your Kotlin-specific improvements
// git mergetool # Resolve conflicts
//
// # Remember conflict resolutions for repeated merges
// git config rerere.enabled true
//
// # After resolving:
// git add .
// git commit -m "Merge upstream Android 15 changes, re-convert to Kotlin"Line-by-line walkthrough
- 1. @NonNull on getUserName() tells Kotlin this is a guaranteed non-null String — the compiler allows direct usage without null checks
- 2. @Nullable on getUserBio() tells Kotlin this is String? — the compiler forces null handling via ?., ?:, or explicit checks
- 3. getUserEmail() with NO annotation becomes String! (platform type) — the compiler gives NO warning, but a runtime NPE can occur; always assign to an explicitly typed variable: val email: String?
- 4. @JvmField on MAX_RETRIES removes the getter/setter wrapper — Java accesses it as ApiConfig.MAX_RETRIES instead of ApiConfig.Companion.getMAX_RETRIES()
- 5. @JvmStatic on create() generates a real static method on ApiConfig — Java calls ApiConfig.create() naturally instead of ApiConfig.Companion.create()
- 6. @JvmOverloads on UserQuery constructor generates 5 Java constructors with progressively more parameters — each uses Kotlin's default values for omitted params
- 7. The SAM conversion example shows Kotlin automatically converting a lambda to View.OnClickListener — this works because it's a Java interface with exactly one abstract method
- 8. The migration example transforms Java's findViewById + manual lifecycle to Kotlin's ViewBinding + ViewModel + LiveData — this is idiomatic migration, not just syntax conversion
- 9. fun interface Validator enables SAM conversion for a Kotlin-defined interface — without the 'fun' keyword, callers would need object : Validator { override fun validate(...) }
- 10. git config rerere.enabled true tells git to Remember Recorded Resolutions — critical for fork maintenance where the same type of conflict (Java vs Kotlin) recurs on every upstream merge
Spot the bug
// Java class (cannot modify):
// public class LegacyApi {
// public static List<String> getUsers() { ... }
// public static String findUser(String query) { ... }
// }
// Kotlin code calling Java:
fun displayUsers() {
val users = LegacyApi.getUsers()
users.forEach { user ->
val length = user.length // Potential crash!
println("User (${length} chars): ${user}")
}
}
fun searchUser(query: String): String {
val result = LegacyApi.findUser(query)
return result.uppercase() // Potential crash!
}
// Kotlin class consumed by Java code:
class EventTracker {
companion object {
val SCREEN_VIEW = "screen_view"
fun getInstance(): EventTracker = EventTracker()
}
fun track(event: String, label: String = "none", value: Int = 0) {
println("Track: ${event}, ${label}, ${value}")
}
}
// Java calling Kotlin (won't compile!):
// EventTracker.SCREEN_VIEW // Error: cannot find symbol
// EventTracker.getInstance() // Error: cannot find symbol
// tracker.track("click") // Error: missing parametersNeed a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Calling Java from Kotlin (kotlinlang.org)
- Calling Kotlin from Java (kotlinlang.org)
- Migrating to Kotlin — Android Guide (developer.android.com)
- Java-Kotlin Interop Best Practices (developer.android.com)
- kotlinx.serialization Guide (kotlinlang.org)