Tips for designing your Kotlin SDK: Supporting Java users

At Daily, we’ve built our Client SDK for Android using Kotlin (on top of a Rust core). However, many Android apps are written in Java and interoperability is very important for us.

Luckily, Kotlin was designed from the ground up to make interacting with Java code easy, in both directions.

This article offers some handy tips that we’ve compiled while building our Kotlin SDK, which make life easier for Java users!

Offer a Builder class as an alternative to named constructor arguments

Since Kotlin’s named arguments are not supported in Java, it can be helpful to offer an alternative, to avoid an API which is ergonomic in Kotlin becoming unwieldy and unclear when used from Java. When dealing with constructors, one such alternative is the Builder pattern. This lets users build an object step-by-step, providing only the arguments they actually need, while explicitly specifying which value corresponds to which parameter.

Named arguments are one of many convenient features available to Kotlin developers. If you’re calling a function with a large number of parameters, you can specify the name of each argument at the point where you call it.

The major benefit of this is that it’s easy to see, at the call site, which values are being provided for which parameters. This makes the code more readable and reduces the likelihood of mix-ups, particularly where many parameters share the same type:

data class Person(
    val name: String,
    val favoriteFood: String?,
    val favoriteBook: String?,
    val favoriteColor: String?
)

// Without named arguments:
val bob = Person(
    "Bob",
    null,
    null,
    "Blue"
)

// With named arguments:
val bob = Person(
    name = "Bob",
    favoriteFood = null,
    favoriteBook = null,
    favoriteColor = "Blue"
)

When combined with default arguments, this becomes even more powerful, as some arguments can simply be omitted from the call:

data class Person(
    val name: String,
    val favoriteFood: String? = null,
    val favoriteBook: String? = null,
    val favoriteColor: String? = null
)

val bob = Person(
    name = "Bob",
    favoriteColor = "Blue"
)

However, unfortunately Java doesn’t support named or default arguments, so calling this constructor from Java code is cumbersome and unclear:

new Person("Bob", null, null, "Blue");

One way to offer Java developers the same convenience (with a bit more verbosity!) is to offer a Builder in addition to the main constructor:

data class Person(
    val name: String,
    val favoriteFood: String? = null,
    val favoriteBook: String? = null,
    val favoriteColor: String? = null
) {
    class Builder(val name: String) {
        
        private var favoriteFood: String? = null
        private var favoriteBook: String? = null
        private var favoriteColor: String? = null
        
        fun withFavoriteFood(value: String) : Builder {
            favoriteFood = value
            return this
        }

        fun withFavoriteBook(value: String) : Builder {
            favoriteBook = value
            return this
        }

        fun withFavoriteColor(value: String) : Builder {
            favoriteColor = value
            return this
        }
        
        fun build() = Person(
            name = name,
            favoriteFood = favoriteFood,
            favoriteBook = favoriteBook,
            favoriteColor = favoriteColor
        )
    }
}

Now a Java developer can instantiate a Person as follows:

new Person.Builder("Bob").withFavoriteColor("Blue").build();

A bit more verbose, but more clear and less error-prone!

Use @JvmOverloads for default arguments on methods

The Builder pattern is suitable for constructors, but sometimes you want to use default arguments on other functions.

If the parameters have unique types, annotate the function with @JvmOverloads — Kotlin will generate multiple versions of the function for Java users, each of which has a different combination of parameters.

Note that the arguments must be provided in order in the Java call—e.g., if you have three optional parameters, three overloads will be created: (arg1), (arg1, arg2), and (arg1, arg2, arg3).

@JvmOverloads
fun makeHttpsRequest(
    host: String,
    port: Int = 443
)

Calling the above from Kotlin:

makeHttpsRequest("localhost")
makeHttpsRequest("localhost", port = 8080)

And similarly from Java, since we added @JvmOverloads:

makeHttpsRequest("localhost");
makeHttpsRequest("localhost", 8080);

Avoid Companion verbosity

In Kotlin, companion objects are used to add static members to classes. Static members are functions and variables which are associated with the class, but not associated with any specific instance of the class.

class MyClass {
    companion object {
        val TAG = "MyLogTag"

        fun myFunction() {
            Log.i(TAG, "Hello world")
        }
    }
}

Accessing such functions from Java can be cumbersome, because they are located inside a Companion field:

MyClass.Companion.myFunction();

This can be avoided by tagging the function @JvmStatic:

@JvmStatic
fun myFunction() {
    Log.i(TAG, "Hello world")
}

Now, with that annotation added above, we can call the function directly:

MyClass.myFunction();

It’s possible to do something similar for fields. By default, these static fields are accessed in Java using getters and setters in the Companion object:

MyClass.Companion.getTAG();

It’s possible to make this less verbose by making the field const:

const val TAG = "MyLogTag"

Or, if it’s not possible to use const, an alternative is to annotate the field using @JvmField:

@JvmField
val TAG = "MyLogTag"

If either const or @JvmField is used, then the field can be accessed directly from Java using MyClass.TAG.

Specify where checked exceptions are thrown

Java requires that certain types of exceptions, known as checked exceptions, are explicitly specified in the function signature if they are thrown from that function.

public void myJavaFunction() throws IOException

Kotlin doesn’t share this concept—all exceptions are effectively unchecked runtime exceptions.

However, Java code calling your APIs still needs to know which exceptions get thrown. If your Kotlin function throws checked exceptions, you can tag it with the @Throws annotation:

@Throws(IOException::class)
fun myKotlinFunction()

Note that this will have no effect on Kotlin callers—the compiler won’t warn them if they fail to catch the exception!

Generate default interface methods for Java

Interfaces in both Java 8 and Kotlin support default methods, which are “base” implementations that get used if the implementing class doesn’t provide its own override.

However, Java and Kotlin represent these methods differently at the bytecode level. You can configure the Kotlin compiler to generate the Java equivalents of your Kotlin default methods, by adding the -Xjvm-default=all flag to the Kotlin compiler.

Here’s an example if you’re using Gradle:

kotlinOptions {
    jvmTarget = JavaVersion.VERSION_1_8

    // Ensure that we generate JVM defaults for interfaces
    freeCompilerArgs = ["-Xjvm-default=all"]
}

Avoid the internal modifier

Kotlin supports the internal visibility modifier, which specifies that something is visible within the current module, but not outside it.

However, Java does not support this, so any internal class members will be public when used from Java.

While the names of these members get mangled to make accidental usage less likely, those mangled names will show up in IDE autocompletion.

So, it’s best to avoid internal in your API and instead make members private where possible.

Conclusion

In this post, we've demonstrated how to fix some of the interop challenges we faced in developing Daily's Client SDK for Android. I hope these tips can be helpful to other developers working with Kotlin and Java!

For additional information about Java and Kotlin interop, check out the following resources in official Kotlin documentation:

If you have any questions or just want to chat more about all things Android, head over to our WebRTC community.

Never miss a story

Get the latest direct to your inbox.