Lesson 83 of 83 advanced

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

Java-Kotlin interop is like two people who speak related languages (say Spanish and Portuguese). They understand each other mostly, but some words have different meanings (null!), some grammar rules clash (default parameters), and you need a translator's phrasebook (@JvmStatic, @JvmOverloads) to avoid miscommunication. Migration is like gradually renovating a house room by room while people still live in it — you can't tear everything down at once.

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

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. 1. @NonNull on getUserName() tells Kotlin this is a guaranteed non-null String — the compiler allows direct usage without null checks
  2. 2. @Nullable on getUserBio() tells Kotlin this is String? — the compiler forces null handling via ?., ?:, or explicit checks
  3. 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. 4. @JvmField on MAX_RETRIES removes the getter/setter wrapper — Java accesses it as ApiConfig.MAX_RETRIES instead of ApiConfig.Companion.getMAX_RETRIES()
  5. 5. @JvmStatic on create() generates a real static method on ApiConfig — Java calls ApiConfig.create() naturally instead of ApiConfig.Companion.create()
  6. 6. @JvmOverloads on UserQuery constructor generates 5 Java constructors with progressively more parameters — each uses Kotlin's default values for omitted params
  7. 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. 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. 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. 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 parameters
Need a hint?
Three categories of bugs: (1) Platform types from Java can be null but Kotlin doesn't warn you, (2) Companion object members need @JvmField/@JvmStatic for Java access, (3) Default parameters need @JvmOverloads for Java callers
Show answer
Bug 1: LegacyApi.getUsers() returns List<String!>! — any element could be null, and user.length crashes on null elements. Fix: val users: List<String?> = LegacyApi.getUsers(), then user?.length. Bug 2: LegacyApi.findUser() returns String! — could be null, and result.uppercase() throws NPE. Fix: val result: String? = LegacyApi.findUser(query), return result?.uppercase() ?: 'Not found'. Bug 3: Java can't access SCREEN_VIEW without @JvmField, can't call getInstance() without @JvmStatic, and can't use default parameters without @JvmOverloads. Fix: @JvmField val SCREEN_VIEW, @JvmStatic fun getInstance(), and @JvmOverloads fun track(...).

Explain like I'm 5

Imagine you speak English and your friend speaks French. You can mostly understand each other because the languages are related, but sometimes a word means something different — like 'null' in Java means 'could be anything' but in Kotlin it means 'DANGER! CHECK FIRST!' To help you communicate better, you use special stickers (@JvmStatic, @JvmOverloads) on your notes so your friend knows exactly what you mean. Moving the whole team from French to English is like migrating — you teach one room at a time, not the whole school at once.

Fun fact

Kotlin was designed from day one to be 100% Java interoperable. JetBrains' internal codename for the project was 'Project Kotlin' — named after Kotlin Island near Saint Petersburg, Russia, mirroring how Java was named after the Indonesian island. When Google announced Kotlin as an official Android language at Google I/O 2017, they revealed that over 40% of professional Android developers had already adopted it. By 2024, Google reported that 95% of the top 1000 Android apps use Kotlin.

Hands-on challenge

You are given a legacy Java Android app with a UserRepository (Java, no nullability annotations), a UserService (Java, uses callbacks), and a UserListActivity (Java, uses findViewById and AsyncTask). Design and execute a migration plan: (1) Add appropriate @Nullable/@NonNull annotations to the Java UserRepository interface, (2) Write a Kotlin UserViewModel that calls the Java repository safely handling platform types, (3) Convert UserListActivity to Kotlin idiomatically (not just auto-convert — use ViewBinding, ViewModel, LiveData), (4) Add @JvmStatic and @JvmOverloads to your Kotlin code so remaining Java code can call it easily. Document the order you'd do this in production and explain why.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery