This is the fifth part of a series of articles that dive into creating a new Android application from scratch using Jetpack Compose for the user interface and some other tools offered by Google, like Firebase Authentication, Crashlytics, Cloud Firestore and Hilt.

Part 1 of this series has an overview of what you can do with this app and how it is structured. Part 2 shows how to implement login and sign up flows using Firebase Authentication. Part 3 covers how to add Firebase Crashlytics to handle crashes and display messages to the user, and part 4 covers how to store data in the cloud using Cloud Firestore.

In this fifth part, we will see how to change the services in our codebase to use Kotlin Coroutines, instead of callbacks, to return the result of calls to the ViewModels. Using Kotlin Coroutines will also enable us to add Kotlin Flow to our application!

Recap

Before we jump into how to change the services and use Coroutines and Flow, let’s remember how it works using the callback approach to call Firebase API methods and return the response to the ViewModels.

Firebase Authentication

We can see in the snippet below what the authentication service interface used to look like. Each one of the three methods shown below (for creating an anonymous account, authenticating via email and password, and linking credentials) has a callback called onResult as a parameter:

interface AccountService {
    fun createAnonymousAccount(onResult: (Throwable?) -> Unit)
    fun authenticate(email: String, password: String, onResult: (Throwable?) -> Unit)
    fun linkAccount(email: String, password: String, onResult: (Throwable?) -> Unit)
}

If we navigate to the authentication service implementation, we can see that these callbacks are called within the addOnCompleteListener block. This listener is added to each of the calls to the authentication API, and inside it we have access to the response sent by the server. Then we pass the result back to the ViewModels via the callbacks:

override fun createAnonymousAccount(onResult: (Throwable?) -> Unit) {
    Firebase.auth.signInAnonymously()
        .addOnCompleteListener { onResult(it.exception) }
}

override fun authenticate(email: String, password: String, onResult: (Throwable?) -> Unit) {
    Firebase.auth.signInWithEmailAndPassword(email, password)
        .addOnCompleteListener { onResult(it.exception) }
}

override fun linkAccount(email: String, password: String, onResult: (Throwable?) -> Unit) {
    val credential = EmailAuthProvider.getCredential(email, password)

    Firebase.auth.currentUser!!.linkWithCredential(credential)
        .addOnCompleteListener { onResult(it.exception) }
}

The code snippet below was taken from the LoginViewModel's onSignInClick method. We are passing the email and password as parameters, and soon after we open a code block that corresponds to the callback. All the code inside this block will be executed once the Firebase Authentication API sends a response:

viewModelScope.launch(showErrorExceptionHandler) {
    accountService.authenticate(email, password) { error ->
         if (error == null) {
             openAndPopUp(SETTINGS_SCREEN, LOGIN_SCREEN)
         } else onError(error)
     }
}

Cloud Firestore

Just as we saw earlier in the authentication service, most of our storage service's methods will also pass a callback as a parameter:

interface StorageService {
    fun addListener(
        userId: String,
        onDocumentEvent: (Boolean, Task) -> Unit,
        onError: (Throwable) -> Unit
    )

    fun removeListener()
    fun getTask(taskId: String, onError: (Throwable) -> Unit, onSuccess: (Task) -> Unit)
    fun saveTask(task: Task, onResult: (Throwable?) -> Unit)
    fun updateTask(task: Task, onResult: (Throwable?) -> Unit)
    fun deleteTask(taskId: String, onResult: (Throwable?) -> Unit)
    fun deleteAllForUser(userId: String, onResult: (Throwable?) -> Unit)
}

In the implementation of the method that adds a listener to the collection of documents stored in Firestore, we can see that two callbacks are passed in: onError and onDocumentEvent. Every time a document is added, changed or deleted from the collection, the code block inside addSnapshotListener will be executed, and will decide which of the callbacks should be called (in case of success or error):

override fun addListener(
    userId: String,
    onDocumentEvent: (Boolean, Task) -> Unit,
    onError: (Throwable) -> Unit
) {
    val query = Firebase.firestore.collection(TASK_COLLECTION).whereEqualTo(USER_ID, userId)

    listenerRegistration = query.addSnapshotListener { value, error ->
        if (error != null) {
            onError(error)
            return@addSnapshotListener
        }

        value?.documentChanges?.forEach {
            val wasDocumentDeleted = it.type == REMOVED
            val task = it.document.toObject<Task>().copy(id = it.document.id)
            onDocumentEvent(wasDocumentDeleted, task)
        }
    }
}

In TasksViewModel, when we call the StorageService's addListener method, we pass the onDocumentEvent and onError callbacks. Just below the addListener method, we can see what happens in case we receive an event successfully: if the document was removed from the collection, we also remove it from the tasks list. Otherwise, we update the task in the list that the ViewModel maintains:

val tasks = mutableStateMapOf<String, Task>()
    private set

fun addListener() {
    viewModelScope.launch(showErrorExceptionHandler) {
        storageService.addListener(accountService.getUserId(), ::onDocumentEvent, ::onError)
    }
}

private fun onDocumentEvent(wasDocumentDeleted: Boolean, task: Task) {
    if (wasDocumentDeleted) tasks.remove(task.id) else tasks[task.id] = task
}

Finally, in the composable function that represents the TasksScreen, we access the tasks list in the TasksViewModel, extract the last published value (since it is a mutableState) and convert it from map to list, which is the format that the LazyColumn needs in order to show the items on the screen:

LazyColumn {
  items(viewModel.tasks.values.toList(), key = { it.id }) { taskItem ->
    TaskItem(
      task = taskItem,
      [...]
    )
  }
}

Adding Coroutines and Flow

Now we want to update the services we just saw to use Kotlin Coroutines, which are lightweight threads that enable you to write asynchronous code. We also want to use Kotlin Flow, which is a data stream that emits sequential values, and is recommended by the Android team as a way to exchange information between the different layers of your application.

As Firebase aims to always follow the best practices and deliver the best experience to developers, we are adding support for Flow to our Firebase SDKs for Android as well. So let’s dive into how to use Coroutines with the Authentication API, and how to use both Coroutines and Flow with the Cloud Firestore API!

Firebase Authentication

Below we can see what the authentication service interface looks like now that we are using Coroutines. The first thing we need to do is add the suspend keyword to the beginning of each function. A suspending function is simply a function that can be paused and resumed at a later time. The second thing we need to do is remove the callbacks from the parameters. If it is necessary to return some data, we can return it directly in the implementation of the function, as it was a synchronous call.

interface AccountService {
  suspend fun createAnonymousAccount()
  suspend fun authenticate(email: String, password: String)
  suspend fun linkAccount(email: String, password: String)
}

Now in the service implementation, we don’t have to use the addOnCompleteListener in the API calls anymore, because we no longer need to pass the result to a callback. What we do instead, is use the await() method. Once you call await(), you suspend the function call, thus preventing the thread from being blocked. Note that you can only call await() in a suspend function:

override suspend fun createAnonymousAccount() {
  auth.signInAnonymously().await()
}

override suspend fun authenticate(email: String, password: String) {
    auth.signInWithEmailAndPassword(email, password).await()
  }

override suspend fun linkAccount(email: String, password: String) {
    val credential = EmailAuthProvider.getCredential(email, password)
    auth.currentUser!!.linkWithCredential(credential).await()
}

And finally, now the service call in the onSignInClick method is as seen in the code snippet below. First we try to authenticate, and if the call succeeds, we proceed to the next screen. As we are executing these calls inside a launchCatching block (this is a method of MakeItSoViewModel - the base ViewModel that all other ViewModels extend that uses lifecycleScope to seamlessly integrate with the apps lifecycle), if an error happens on the first line, the exception will be caught and handled, and the second line will not be reached at all. In this way, we can write asynchronous code that looks synchronous which improves code readability:

launchCatching {
    accountService.authenticate(email, password)
    openAndPopUp(SETTINGS_SCREEN, LOGIN_SCREEN)
}

Cloud Firestore

Before we jump into how to update Cloud Firestore calls to use Coroutines and Flow, it is important to make clear that the minimum version of the Firebase Bill of Materials that you can use is 31.0.0. All versions before this one won’t have support for Flow in the Cloud Firestore SDK, so check your build.gradle file to make sure you are using at least this version.

Once the BoM version is up to date, it is possible to update the StorageService interface to look like the code snippet below. As with the authentication service, all callbacks were removed from the parameters, and we added the keyword suspend to the beginning of each function. Also, we made the tasks list a Flow:

interface StorageService {
  val tasks: Flow<List<Task>>

  suspend fun getTask(taskId: String): Task?
  suspend fun save(task: Task): String
  suspend fun update(task: Task)
  suspend fun delete(taskId: String)
  suspend fun deleteAllForUser(userId: String)
}

Below we can see the code that is executed when someone accesses the tasks Flow: we take the current user available in the authentication service, and we use this user's id to search for documents in the task collection that belong to this user. And with each new snapshot, we map each document to an object that our code understands (a data class Task object) through the toObjects() method provided by the Firestore API:

override val tasks: Flow<List<Task>>
  get() =
    auth.currentUser.flatMapLatest { user ->
      currentCollection(user.id).snapshots().map { snapshot ->
        snapshot.toObjects()
      }
    }

Now it's even easier for TasksViewModel. Instead of listening to events and performing operations on a list maintained locally, now we only need to declare that the tasks Flow in the TasksViewModel is the same as the service's tasks Flow. Now every time a new version of the Tasks is available in the service, it will be available in the TasksViewModel as well:

@HiltViewModel
class TasksViewModel @Inject constructor(
  private val storageService: StorageService,
  private val logService: LogService
) : MakeItSoViewModel(logService) {

  val tasks = storageService.tasks

  [...]
}

Finally, the TasksScreen can easily access and collect the most recent values from the Flow of tasks available in the TasksViewModel. The only thing the TasksScreen has to do is transform it to a list, which is the format that the LazyColumn needs to show the items on the screen. We do this by using collectAsStateWithLifecycle. This is an extension function from the lifecycle-runtime-compose API that aims to help developers to safely collect new states, while being aware of the lifecycle of the app:

val tasks = viewModel.tasks.collectAsStateWithLifecycle(emptyList())

LazyColumn {
  items(tasks.value, key = { it.id }) { taskItem ->
    TaskItem(
      task = taskItem,
      [...]
    )
  }
}

And that's it! You are all set to use Kotlin Coroutines and Flow in your app.

What’s next

In part 6 of this series we will see how to monitor the quality of your application by adding Firebase Performance Monitoring to it. We will cover what is automatically measured by this product, and how you can add custom traces to monitor specific parts of your codebase.

You can clone the source code on the Github repository. If you’re also interested in iOS/Mac development, the same application is available for these platforms as well. The iOS/Mac version uses SwiftUI for building the user interface, which also follows the declarative UI model, similarly to how we build UIs in Compose. You can find the source code in this Github repository.

If you have any questions you reach out to me through Twitter.