Building a Kotlin extensions library

1. Introduction

Android KTX is a set of extensions for commonly used Android framework APIs, Android Jetpack libraries, and more. We built these extensions to make calling into Java programming language-based APIs from Kotlin code more concise and idiomatic by leveraging language features such as extension functions and properties, lambdas, named and default parameters, and coroutines.

What is a KTX library?

KTX stands for Kotlin extensions, and it's not a special technology or language feature of the Kotlin language in itself. It's just a name that we adopted for Google's Kotlin libraries that extend the functionality of APIs made originally in the Java programming language.

The nice thing about Kotlin extensions is that anyone can build their own library for their own APIs, or even for third-party libraries that you use in your projects.

This codelab will walk you through some examples of adding simple extensions that take advantage of Kotlin language features. We'll also take a look at how we can convert an asynchronous call in a callback-based API into a suspending function and a Flow - a coroutines-based asynchronous stream.

What you will build

In this codelab, you're going to work on a simple application that obtains and displays the user's current location. Your app will:

  • Get the latest known location from the location provider.
  • Register for live updates of the user's location while the app is running.
  • Display the location on the screen and handle error states if the location is not available.

What you'll learn

  • How to add Kotlin extensions on top of existing classes
  • How to convert an async call returning a single result to a coroutine suspend function
  • How to use Flow to obtain data from a source that can emit a value many times

What you'll need

  • A recent version of Android Studio (3.6+ recommended)
  • The Android Emulator or a device connected via USB
  • Basic level knowledge of Android development and the Kotlin language
  • Basic understanding of coroutines and suspending functions

2. Getting set up

Download the Code

Click the following link to download all the code for this codelab:

... or clone the GitHub repository from the command line by using the following command:

$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git

The code for this codelab is in the ktx-library-codelab directory.

In the project directory, you will find several step-NN folders which contain the desired end state of each step of this codelab for reference.

We'll be doing all our coding work in the work directory.

Running the app for the first time

Open the root folder (ktx-library-codelab) in Android Studio, then select the work-app run configuration in the dropdown as shown below:

79c2a2d2f9bbb388.png

Press the Run 35a622f38049c660.png button to test your app:

58b6a81af969abf0.png

This app is not doing anything interesting yet. It's missing a few parts to be able to display data. We'll add the missing functionality in the subsequent steps.

3. Introducing extension functions

An easier way to check for permissions

58b6a81af969abf0.png

Even though the app runs, it simply shows an error - it's unable to get the current location.

That's because it's missing the code for requesting the runtime location permission from the user.

Open MainActivity.kt, and find the following commented-out code:

//  val permissionApproved = ActivityCompat.checkSelfPermission(
//      this,
//      Manifest.permission.ACCESS_FINE_LOCATION
//  ) == PackageManager.PERMISSION_GRANTED
//  if (!permissionApproved) {
//      requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 0)
//  }

If you uncomment the code and run the app, it will request the permission and proceed to showing the location. However, this code is difficult to read for several reasons:

  • It uses a static method checkSelfPermission from the ActivityCompat utility class, that exists only to hold methods for backwards-compatibility.
  • The method always takes an Activity instance as the first parameter, because it's impossible to add a method to a framework class in the Java programming language.
  • We're always checking if the permission was PERMISSION_GRANTED, so it would be nicer to directly get a boolean true if the permission is granted and false otherwise.

We want to convert the verbose code shown above to something shorter like this:

if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
    // request permission
}

We're going to shorten the code with the help of an extension function on Activity. In the project, you'll find another module called myktxlibrary. Open ActivityUtils.kt from that module, and add the following function:

fun Activity.hasPermission(permission: String): Boolean {
    return ActivityCompat.checkSelfPermission(
        this,
        permission
    ) == PackageManager.PERMISSION_GRANTED
}

Let's unpack what is happening here:

  • fun in the outermost scope (not inside a class) means we're defining a top-level function in the file.
  • Activity.hasPermission defines an extension function with the name hasPermission on a receiver of type Activity.
  • It takes the permission as a String argument and returns a Boolean that indicates whether the permission was granted.

So what exactly is a "receiver of type X"?

You will see this very often when reading documentation for Kotlin extension functions. It means that this function will always be called on an instance of an Activity (in our case) or its subclasses, and inside the function body we can refer to that instance using the keyword this (which can also be implicit, meaning we can omit it entirely).

This is really the whole point of extension functions: adding new functionality on top of a class that we can't or don't want to otherwise change.

Let's look at how we would call it in our MainActivity.kt. Open it up and change the permissions code to:

if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
   requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 0)
}

If you run the app now, you can see the location displayed on the screen.

c040ceb7a6bfb27b.png

A helper for formatting Location text

The location text isn't looking too great though! It's using the default Location.toString method, which wasn't made to be displayed in a UI.

Open the LocationUtils.kt class in myktxlibrary. This file contains extensions for the Location class. Complete the Location.format extension function to return a formatted String, and then modify Activity.showLocation in ActivityUtils.kt to make use of the extension.

You can take at the code in the step-03 folder if you're having trouble. This is what the end result should look like:

b8ef64975551f2a.png

4. The location API and async calls

Fused Location Provider from Google Play Services

The app project we're working on uses the Fused Location Provider from Google Play Services to get location data. The API itself is fairly simple, but due to the fact that obtaining a user location is not an instantaneous operation, all calls into the library need to be asynchronous, which complicates our code with callbacks.

There are two parts to getting a user's location. In this step, we're going to focus on getting the last known location, if it's available. In the next step, we're going to look at periodic location updates when the app is running.

Obtaining the last known location

In Activity.onCreate, we're initializing the FusedLocationProviderClient which will be our entry point to the library.

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)
   fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
}

In Activity.onStart, we then invoke getLastKnownLocation() which currently looks like this:

private fun getLastKnownLocation() {
   fusedLocationClient.lastLocation.addOnSuccessListener { lastLocation ->
       showLocation(R.id.textView, lastLocation)
   }.addOnFailureListener { e ->
       findAndSetText(R.id.textView, "Unable to get location.")
       e.printStackTrace()
   }
}

As you can see, lastLocation is an async call that can complete with a success or failure. For each of these outcomes, we have to register a callback function that will either set the location to the UI or show an error message.

This code doesn't necessarily look very complicated because of callbacks now, but in a real project you might want to process the location, save it to a database, or upload to a server. Many of these operations are also asynchronous, and adding callbacks upon callbacks would quickly make our code unreadable and could look something like this:

private fun getLastKnownLocation() {
   fusedLocationClient.lastLocation.addOnSuccessListener { lastLocation ->
       getLastLocationFromDB().addOnSuccessListener {
           if (it != location) {
               saveLocationToDb(location).addOnSuccessListener {
                   showLocation(R.id.textView, lastLocation)
               }
           }
       }.addOnFailureListener { e ->
           findAndSetText(R.id.textView, "Unable to read location from DB.")
           e.printStackTrace()
       }
   }.addOnFailureListener { e ->
       findAndSetText(R.id.textView, "Unable to get location.")
       e.printStackTrace()
   }
}

Even worse, the above code has problems with leaking memory and operations, because the listeners are never removed when the containing Activity finishes.

We're going to look for a better way to solve this with coroutines, which let you write asynchronous code that looks just like a regular, top-down, imperative block of code without doing any blocking calls on the calling thread. On top of that, coroutines are also cancellable, letting us clean up whenever they go out of scope.

In the next step, we'll be adding an extension function that converts the existing callback API into a suspend function that is callable from a coroutine scope tied to your UI. We want the end result to look something like this:

private fun getLastKnownLocation() {
    try {
        val lastLocation = fusedLocationClient.awaitLastLocation();
        // process lastLocation here if needed
        showLocation(R.id.textView, lastLocation)
    } (e: Exception) {
        // we can do regular exception handling here or let it throw outside the function
    }
}

5. Convert one-shot async requests to a coroutine

Creating a suspend function using suspendCancellableCoroutine

Open up LocationUtils.kt, and define a new extension function on the FusedLocationProviderClient:

suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
   suspendCancellableCoroutine { continuation ->
    TODO("Return results from the lastLocation call here")
}

Before we go into the implementation part, let's unpack this function signature:

  • You already know about the extension function and receiver type from the previous parts of this codelab: fun FusedLocationProviderClient.awaitLastLocation
  • suspend means this will be a suspending function, a special type of function that can only be called within a coroutine or from another suspend function
  • The result type of calling it will be Location, as if it were a synchronous way of getting a location result from the API.

To build the result, we are going to use suspendCancellableCoroutine, a low-level building block for creating suspending functions from the coroutines library.

suspendCancellableCoroutine executes the block of code passed to it as a parameter, then suspends the coroutine execution while waiting for a result.

Let's try adding the success and failure callbacks to our function body, like we've seen in the previous lastLocation call. Unfortunately, as you can see in the comments below, the obvious thing that we'd like to do - returning a result - is not possible in the callback body:

suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
   suspendCancellableCoroutine { continuation ->
    lastLocation.addOnSuccessListener { location ->
        // this is not allowed here:
        // return location
    }.addOnFailureListener { e ->
        // this will not work as intended:
        // throw e
    }
}

That's because the callback happens long after the surrounding function has finished, and there's nowhere to return the result. That's where suspendCancellableCoroutine comes in with the continuation that is provided to our block of code. We can use it to provide a result back to the suspended function some time in the future, using continuation.resume. Handle the error case using continuation.resumeWithException(e) to properly propagate the exception to the call site.

In general you should always make sure that at some point in the future, you will either return a result or an exception to not keep the coroutine suspended forever while waiting for a result.

suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
   suspendCancellableCoroutine<Location> { continuation ->
       lastLocation.addOnSuccessListener { location ->
           continuation.resume(location)
       }.addOnFailureListener { e ->
           continuation.resumeWithException(e)
       }
   }

That's it! We've just exposed a suspend version of the last known location API that can be consumed from coroutines in our app.

Calling a suspending function

Let's modify our getLastKnownLocation function in MainActivity to call the new coroutine version of the last known location call:

private suspend fun getLastKnownLocation() {
    try {
        val lastLocation = fusedLocationClient.awaitLastLocation()
        showLocation(R.id.textView, lastLocation)
    } catch (e: Exception) {
        findAndSetText(R.id.textView, "Unable to get location.")
        Log.d(TAG, "Unable to get location", e)
    }
}

As mentioned before, suspending functions always need to be called from other suspending functions to ensure they're running within a coroutine, which means we have to add a suspend modifier to the getLastKnownLocation function itself, or we'll get an error from the IDE.

Notice that we're able to use a regular try-catch block for exception handling. We can move this code from inside the failure callback because exceptions coming from the Location API are now propagated correctly, just like in a regular, imperative program.

To start a coroutine, we would normally use CoroutineScope.launch, for which we need a coroutine scope. Fortunately, Android KTX libraries come with several predefined scopes for common lifecycle objects such as Activity, Fragment, and ViewModel.

Add the following code to Activity.onStart:

override fun onStart() {
   super.onStart()
   if (!hasPermission(ACCESS_FINE_LOCATION)) {
       requestPermissions(arrayOf(ACCESS_FINE_LOCATION), 0)
   }

   lifecycleScope.launch {
       try {
           getLastKnownLocation()
       } catch (e: Exception) {
           findAndSetText(R.id.textView, "Unable to get location.")
           Log.d(TAG, "Unable to get location", e)
       }
   }
   startUpdatingLocation()
}

You should be able to run your app and verify that it works before moving on to the next step, where we'll introduce Flow for a function that emits location results multiple times.

6. Building a Flow for streaming data

Now we're going to focus on the startUpdatingLocation() function. In the current code, we register a listener with the Fused Location Provider to get periodic location updates whenever the user's device moves in the real world.

To show what we want to achieve with a Flow based API, let's first look at the parts of MainActivity that we'll be removing in this section, moving them instead to implementation details of our new extension function.

In our current code, there's a variable for tracking if we started listening to updates:

var listeningToUpdates = false

There's also a subclass of the base callback class and our implementation for the location updated callback function:

private val locationCallback: LocationCallback = object : LocationCallback() {
   override fun onLocationResult(locationResult: LocationResult?) {
       if (locationResult != null) {
           showLocation(R.id.textView, locationResult.lastLocation)
       }
   }
}

We also have the initial registration of the listener (which can fail if the user didn't grant necessary permissions), together with callbacks since it's an async call:

private fun startUpdatingLocation() {
   fusedLocationClient.requestLocationUpdates(
       createLocationRequest(),
       locationCallback,
       Looper.getMainLooper()
   ).addOnSuccessListener { listeningToUpdates = true }
   .addOnFailureListener { e ->
       findAndSetText(R.id.textView, "Unable to get location.")
       e.printStackTrace()
   }
}

Finally, when the screen is no longer active, we clean up:

override fun onStop() {
   super.onStop()
   if (listeningToUpdates) {
       stopUpdatingLocation()
   }
}

private fun stopUpdatingLocation() {
   fusedLocationClient.removeLocationUpdates(locationCallback)
}

You can go ahead and delete all of those code snippets from MainActivity, leaving just an empty startUpdatingLocation() function that we will use later to start collecting our Flow.

callbackFlow: a Flow builder for callback based APIs

Open LocationUtils.kt again, and define another extension function on the FusedLocationProviderClient:

fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    TODO("Register a location listener")
    TODO("Emit updates on location changes")
    TODO("Clean up listener when finished")
}

There a few things that we need to do here to replicate the functionality that we just deleted from the MainActivity code. We will be using callbackFlow(), a builder function that returns a Flow, which is suitable for emitting data from a callback based API.

The block passed to callbackFlow() is defined with a ProducerScope as its receiver.

noinline block: suspend ProducerScope<T>.() -> Unit

ProducerScope encapsulates the implementation details of a callbackFlow, such as the fact that there is a Channel backing the created Flow. Without going into details, Channels are used internally by some Flow builders and operators, and unless you're writing your own builder/operator, you don't need to concern yourself with these low-level details.

We're simply going to use a few functions that ProducerScope exposes to emit data and manage the state of the Flow.

Let's start by creating a listener for the location API:

fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            for (location in result.locations) {
                offer(location) // emit location into the Flow using ProducerScope.offer
            }
        }
    }

    TODO("Register a location listener")
    TODO("Clean up listener when finished")
}

We'll use ProducerScope.offer to send location data through to the Flow as it comes in.

Next, register the callback with the FusedLocationProviderClient, taking care to handle any errors:

fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            for (location in result.locations) {
                offer(location) // emit location into the Flow using ProducerScope.offer
            }
        }
    }

    requestLocationUpdates(
       createLocationRequest(),
       callback,
       Looper.getMainLooper()
    ).addOnFailureListener { e ->
       close(e) // in case of error, close the Flow
    }

    TODO("Clean up listener when finished")
}

FusedLocationProviderClient.requestLocationUpdates is an asynchronous function (just like lastLocation) that uses callbacks for signalling when it completes successfully and when it fails.

Here, we can ignore the success state, as it simply means that at some point in the future, onLocationResult will be called, and we will start emitting results into the Flow.

In case of failure, we immediately close the Flow with an Exception.

The last thing that you always need to call inside a block passed to callbackFlow is awaitClose. It provides a convenient place to put any cleanup code to release resources in case of completion or cancellation of the Flow (regardless if it happened with an Exception or not):

fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            for (location in result.locations) {
                offer(location) // emit location into the Flow using ProducerScope.offer
            }
        }
    }

    requestLocationUpdates(
       createLocationRequest(),
       callback,
       Looper.getMainLooper()
    ).addOnFailureListener { e ->
       close(e) // in case of exception, close the Flow
    }

    awaitClose {
       removeLocationUpdates(callback) // clean up when Flow collection ends
    }
}

Now that we have all the parts (registering a listener, listening for updates and cleaning up), let's go back to MainActivity to actually make use of the Flow for displaying the location!

Collecting the Flow

Let's modify our startUpdatingLocation function in MainActivity to invoke the Flow builder and start collecting it. A naive implementation might look something like this:

private fun startUpdatingLocation() {
    lifecycleScope.launch {
        fusedLocationClient.locationFlow()
        .conflate()
        .catch { e ->
            findAndSetText(R.id.textView, "Unable to get location.")
            Log.d(TAG, "Unable to get location", e)
        }
        .collect { location ->
            showLocation(R.id.textView, location)
            Log.d(TAG, location.toString())
        }
    }
}

Flow.collect() is a terminal operator that starts the actual operation of the Flow. In it, we will receive all location updates emitted from our callbackFlow builder. Because collect is a suspending function, it must run inside a coroutine, which we launch using the lifecycleScope.

You can also notice the conflate() and catch() intermediate operators that are called on the Flow. There are many operators that come with the coroutines library that let you filter and transform flows in a declarative manner.

Conflating a flow means that we only ever want to receive the latest update, whenever the updates are emitted faster than the collector can process them. It fits our example well, because we only ever want to show the latest location in the UI.

catch, as the name suggests, will allow you to handle any exceptions that were thrown upstream, in this case in the locationFlow builder. You can think of upstream as the operations that are applied before the current one.

So what is the problem in the snippet above? While it won't crash the app, and it will properly clean up after the activity is DESTROYED (thanks to lifecycleScope), it doesn't take into account when the activity is stopped (e.g. when it is not visible).

It means that not only we'll be updating the UI when it is not necessary, the Flow will keep the subscription to location data active and waste battery and CPU cycles!

One way to fix this, is to convert the Flow to a LiveData using the Flow.asLiveData extension from the LiveData KTX library. LiveData knows when to observe and when to pause the subscription, and will restart the underlying Flow as needed.

private fun startUpdatingLocation() {
    fusedLocationClient.locationFlow()
        .conflate()
        .catch { e ->
            findAndSetText(R.id.textView, "Unable to get location.")
            Log.d(TAG, "Unable to get location", e)
        }
        .asLiveData()
        .observe(this, Observer { location ->
            showLocation(R.id.textView, location)
            Log.d(TAG, location.toString())
        })
}

The explicit lifecycleScope.launch is no longer needed because asLiveData will supply the necessary scope to run the Flow in. The observe call actually comes from LiveData and has nothing to do with coroutines or Flow, it's just the standard way of observing a LiveData with a LifecycleOwner. The LiveData will collect the underlying Flow and emit the locations to its observer.

Because flow re-creation and collection will be handled automatically now, we should move our startUpdatingLocation() method from Activity.onStart (which can execute many times) to Activity.onCreate:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)
   fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
   startUpdatingLocation()
}

You can now run your app and check how it reacts to rotation, pressing the Home and Back button. Check the logcat to see if new locations are being printed when the app is in the background. If the implementation was correct, the app should properly pause and restart the Flow collection when you press Home and then return to the app.

7. Wrap up

You've just built your first KTX library!

Congratulations! What you've achieved in this codelab is very similar to what would normally happen when building a Kotlin extensions library for an existing Java-based API.

To recap what we've done:

  • You added a convenience function for checking permissions from an Activity.
  • You provided a text formatting extension on the Location object.
  • You exposed a coroutine version of the Location APIs for getting the last known location and for periodic location updates using Flow.
  • If you want, you could further clean up the code, add some tests, and distribute your location-ktx library to other developers on your team so they can benefit from it.

To build an AAR file for distribution, run the :myktxlibrary:bundleReleaseAar task.

You can follow similar steps for any other API that could benefit from Kotlin extensions.

Refining the application architecture with Flows

We've mentioned before that launching operations from the Activity as we did in this codelab is not always the best idea. You can follow this codelab to learn how to observe flows from ViewModels in your UI, how flows can interoperate with LiveData, and how you can design your app around using data streams.