All posts Software

Bringing Kotlin into a Java Project

I added Kotlin after the fact to Android projects I had written in Java back in 2020. Instead of converting everything at once, I moved one file at a time. In this post I explain why platform types punch a hole in null safety, where using data classes with Firestore blows up at runtime, how extension functions took the place of my Utils classes, and how I wrapped Firebase calls while moving from AsyncTask to coroutines — including the Java calls that broke and the @Jvm* annotations that fixed them.

DT
Demir Taşdemir Mobile App & Web Developer
— min read

I came into Android through Java in 2020. Everything I wrote back then was Java: activities, adapters, helper classes wrapping Firebase calls. When I started using Kotlin seriously, my first instinct was to convert the whole project over a weekend. I am glad I didn't. This post is what I learned while slowly bringing Kotlin into a working Java project — especially the parts that hurt.

Why converting everything at once is a bad idea

Android Studio has a menu item called "Convert Java File to Kotlin File." I picked an activity, clicked it, the file became Kotlin, and the project compiled. For a moment I thought I was done.

Here is the problem: the converter knows Java code makes no guarantees about null, so it plays it safe everywhere. The file that comes out is full of !! and ?.. You are writing Kotlin, but you are still carrying Java's null behavior around — and on top of that you now have a file that is hard to read. I went through this with a 700-line screen; after the conversion the code compiled and it ran, but not a single line of it looked like Kotlin.

The second problem is sneakier: when a bug shows up after a large conversion, you cannot tell whether it came from the conversion or from the feature you wrote that day. So I settled on this rule: every new file is Kotlin, and old files become Kotlin only when I already have a reason to touch them.

Setup: two languages in the same module

Kotlin and Java can live in the same module, even in the same folder. Dropping a .kt file under src/main/java is enough; you don't have to create a separate source folder.

plugins {
          id 'com.android.application'
          id 'org.jetbrains.kotlin.android'
      }

The build order works like this: the Kotlin compiler runs first and can read the Java sources, then javac takes over and sees the classes Kotlin produced. In practice this means a Java class can call a Kotlin class and a Kotlin class can call a Java class. Even mutual dependencies are fine.

The only real cost is that the Kotlin standard library gets added to the APK. Since R8 strips the unused parts in a release build, this isn't a reason to abandon the migration — but don't be surprised when your debug APK grows.

Everything coming from Java is a "platform type"

Kotlin's null safety rests on the type system distinguishing String from String?. But there is no such information on the Java side. When a Java method returns String, Kotlin treats it as a platform type and shows it as String!: "it may or may not be null, I can't decide."

With platform types the compiler won't warn you. So even though you moved to Kotlin, you can still take the classic NullPointerException right in the face.

// Java side
      public class Kullanici {
          public String getEposta() { return eposta; }   // can return null
      }

      // Kotlin side
      val uzunluk = kullanici.eposta.length   // compiles, can blow up at runtime

The fix is to annotate your Java classes as you go. When the Kotlin compiler sees @Nullable and @NonNull, it treats the type as String? or String and forces you to check. I didn't do this in one sweep; I annotated each Java class the moment I started calling it from Kotlin.

!! is not a fix, it's an alarm

Every !! the converter leaves behind really means "I haven't decided yet what should happen if this is null." I went through them one by one and turned each into an early return, a default value, or a lateinit var.

data class and Firestore: it breaks at runtime, not at compile time

Model classes are the most tempting place to start with Kotlin. A model that took 60 lines in Java comes down to five with a data class, and equals, hashCode, toString and copy come for free. My backend has always been Firebase, so I populate my models from Firestore through toObject() — and this is exactly where I tripped.

Firestore's default converter needs a no-argument constructor to create the object and setters to write the fields. If you write a data class with required parameters in Kotlin, that constructor is never generated; the code compiles and then blows up on the first fetch on the test device. The fix is to give every field a default value and use var:

data class Ilan(
          var id: String = "",
          var baslik: String = "",
          @get:PropertyName("satis_fiyati")
          @set:PropertyName("satis_fiyati")
          var satisFiyati: Long = 0,
          var kapali: Boolean = false
      )

The second detail is @PropertyName. In Java you wrote it above the field and moved on; in Kotlin a property generates a field, a getter and a setter, so you have to say which one the annotation applies to using @get: and @set:. If you write only @PropertyName, reading works but writing goes to the wrong field. I caught this in a project with an auction flow: the record was being saved, but the field came back empty when read.

One more warning: copy() and default parameters are awkward to use from Java. If the model is still being constructed from Java code, it hurts less to start the conversion with the screen that uses the model rather than with the model itself.

Extension functions: the end of Utils classes

Every Java project of mine had a Utils class: date formatting, text truncation, currency printing. In Kotlin these attach to the type itself as extension functions.

@file:JvmName("MetinYardimcilari")

      package com.ornek.util

      fun String.kisalt(sinir: Int): String =
          if (length <= sinir) this else take(sinir).trimEnd() + "…"

These actually compile down to static methods, so the Java side can use them too: MetinYardimcilari.kisalt(baslik, 40). I add @file:JvmName so the file name doesn't turn into an ugly class name like MetinYardimcilariKt.

The thing to keep in mind is that extension functions are resolved statically. The call is picked based on the variable's declared type at compile time, not its actual type at runtime. It doesn't behave like a virtual method; if you don't know that and expect subclass-specific behavior, the result will surprise you.

Calling Kotlin from Java: where it breaks

This was the most annoying part of the migration. The Kotlin file compiles just fine, but the old Java code calling it doesn't. The reason is that some Kotlin features have no direct counterpart on the JVM.

What you want to do from JavaWhat the Kotlin side needs
A static call in the form Sinif.metot()@JvmStatic inside a companion object
Skip default parameters@JvmOverloads
Catch it with try/catch@Throws(IOException::class)
Read a field without a getter@JvmField
Pin down the generated class name@file:JvmName("...")

Without @JvmStatic, the Java side is stuck writing Sinif.Companion.metot(). And a singleton you declared as an object in Kotlin is reached from Java through Sinif.INSTANCE. The checked exception issue is real too: Kotlin has no checked exceptions, so if you don't write @Throws, the Java compiler rejects the catch block on the grounds that the method never throws in the first place.

From AsyncTask to coroutines

AsyncTask was deprecated in API 30, but that wasn't my real problem. My problem was nested listeners: fetch the document from Firestore, then when it arrives fetch the image from Storage, then when that arrives update the UI. Three levels of indentation, with a separate error block at every level.

I didn't change everything at once when moving to coroutines. First I wrapped the Firebase calls in suspend functions:

suspend fun ilanGetir(id: String): Ilan =
          suspendCancellableCoroutine { devam ->
              db.collection("ilanlar").document(id).get()
                  .addOnSuccessListener { snap ->
                      val ilan = snap.toObject(Ilan::class.java)
                      if (ilan == null) devam.resumeWithException(NoSuchElementException(id))
                      else devam.resume(ilan)
                  }
                  .addOnFailureListener { hata -> devam.resumeWithException(hata) }
          }

On the screen side I keep a scope tied to the lifecycle and cancel it in onDestroy:

private val kapsam = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

      override fun onDestroy() {
          kapsam.cancel()
          super.onDestroy()
      }

After that, two sequential calls become two lines and a single try/catch covers both. Putting the heavy work inside withContext(Dispatchers.IO) and leaving the UI update on the main thread is also far more readable in this shape.

Cancellation does not stop the request

Cancelling the scope stops the coroutine, but it does not take back a network request that already started in the background. So once the screen closes the result is no longer used, yet the request still completes. Knowing this going in saves you the "I cancelled it but the spinner is still going" confusion.

The rules I set for myself during the gradual migration

  • A new file is always Kotlin. An old file becomes Kotlin only if I already have a reason to change it.
  • If I use the converter, I don't leave its output as-is; I make a decision about every single !!.
  • In the commit where I convert a file, I change nothing else. That way, if a bug shows up, I know where to look.
  • I leave the model classes that talk to Firestore for last, because their bugs show up at runtime rather than at compile time.
  • I annotate Java classes that will be called from Kotlin with nullability annotations; without that, saying "Kotlin solved my null problem" is just fooling yourself.
Where to start

The best first candidates are helper classes with few external dependencies and clear inputs and outputs. Converting those doesn't break the Java side, the result is easy to test, and you get to see how the language behaves without taking on risk. Starting with activities means entering through the hardest door.

Today, whatever new code I write on Android I write in Kotlin, but there are still Java files inside the older projects and that doesn't bother me. The two work together in the same module without trouble. The value of the migration isn't in the file count going down; it's in the code getting a little more readable everywhere you touch it.

  • Kotlin
  • Java
  • Android
  • Firebase
  • Coroutine
  • Interop
Share: LinkedIn X WhatsApp
DT

Demir Taşdemir

Mobile App & Web Developer

I have been building software since 2018. I have published 11 apps on the App Store and Google Play; right now I am working on 6 mobile apps, 1 e-commerce platform and 1 desktop game.

Want to add Kotlin to your existing project?

If you have an Android app written in Java and you want to write new features in Kotlin, we can work out the gradual migration plan together. Get in touch with me.