How to Make the Firebase Database SDK Work Better With Kotlin

Extensions functions to make your life easier when working with Firebase

Miguel
CodeX
4 min readJan 25, 2023

--

Photo by Marc Reichelt on Unsplash

In this article, we are going straight to the point. 🚀

I want to share some extension functions to make your experience with the Firebase Database a little more comfortable with Kotlin.

We are going to make reusable code that is also going to avoid the need for callbacks and take advantage of suspend functions.

Mapping operations

Here is a function to cast the value of every node of the database to whatever object you need. Thanks to the reified and inline keywords, you can make this transformation in one line with clear looking code.

// This would be called like so, snapshot.toDomain<String>()

inline fun <reified T> DataSnapshot.toDomain(): T? = getValue(T::class.java)

Read operations

For one-shot read operations, you can use the following function. In all the methods that you are going to see in this post, an exception is going to be thrown if the operation fails, so take that into account when using them.

suspend fun DatabaseReference.read(): DataSnapshot = suspendCoroutine { continuation ->
val valueEventListener = object : ValueEventListener {
override fun onCancelled(error: DatabaseError) {
continuation.resumeWithException(error.toException())
}

override fun onDataChange(snapshot: DataSnapshot) {
continuation.resume(snapshot)
}
}
addListenerForSingleValueEvent(valueEventListener)
}

To subscribe to changes in the database, we have the following function that listen to node children, returns a Flow than be collected every time a node creation happens.

To make it a little more interesting, I made it so that you can pass it a mapper function instead of doing the mapping outside.

/* This function can be called like 
* subscribeModifiedChildren(DataSnapshot::toBoolean)
* where toBoolean is
* fun toBoolean() = toDomain<Boolean>() ?: false
*/

suspend fun <T> DatabaseReference.subscribeNewChildren(
mapper: DataSnapshot.() -> T
): Flow<T> = callbackFlow {
val childEventListener = object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
trySend(snapshot.mapper())
}

override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}

override fun onChildRemoved(snapshot: DataSnapshot) {}

override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}

override fun onCancelled(error: DatabaseError) {}
}
addChildEventListener(childEventListener)
awaitClose {
removeEventListener(childEventListener)
}
}

In this next method, we have the same idea as above, but now we are listening changes to a specific node.

@OptIn(InternalCoroutinesApi::class)
suspend fun <T> DatabaseReference.subscribeModifiedChildren(
mapper: DataSnapshot.() -> T
): Flow<T> = callbackFlow {
val valueEventListener = object : ValueEventListener {
override fun onCancelled(error: DatabaseError) {
handleCoroutineException(coroutineContext, error.toException())
}

override fun onDataChange(snapshot: DataSnapshot) {
trySend(snapshot.mapper())
}
}
addValueEventListener(valueEventListener)
awaitClose {
removeEventListener(valueEventListener)
}
}

For queries, we do exactly the same as the read operation, but we extend from the Query object.

suspend fun Query.read(): DataSnapshot = suspendCoroutine { continuation ->
val valueEventListener = object : ValueEventListener {
override fun onCancelled(error: DatabaseError) {
continuation.resumeWithException(error.toException())
}

override fun onDataChange(snapshot: DataSnapshot) {
continuation.resume(snapshot)
}
}
addListenerForSingleValueEvent(valueEventListener)
}

If you want to know some more details about how this code works, you can check this article that I wrote where I go more in detail.

Write operations

The write operation uses the suspendCoroutine again, but now we called the setValue method provided by the Firebase SDK.

suspend fun <T> DatabaseReference.write(data: T) {
suspendCoroutine { continuation ->
setValue(data)
.addOnSuccessListener {
continuation.resume(Unit)
}
.addOnFailureListener {
continuation.resumeWithException(it)
}
}
}

In case you want to get a node or create it if it doesn't exist, you have the following functions. This is pretty useful, for example, if you need to add a new node when a user is trying to register in your app, but he did sign up before and didn't remember.

suspend inline fun <reified T> DatabaseReference.getOrCreate(data: T): DataSnapshot =
suspendCoroutine { continuation ->
val transactionHandler = object : Transaction.Handler {
override fun doTransaction(currentData: MutableData): Transaction.Result {
return if (currentData.getValue<Any>() != null) {
Transaction.success(currentData)
} else {
currentData.value = data
Transaction.success(currentData)
}
}

override fun onComplete(
error: DatabaseError?,
committed: Boolean,
currentData: DataSnapshot?
) {
if (committed && currentData != null) {
continuation.resume(currentData)
} else {
error?.let { throw error.toException() }
}
}
}
runTransaction(transactionHandler)
}

Delete operations

To finish, here is a delete operation. We are going to follow the same technique used in the write function, so that we can know if the operation was completed successfully or if we need to manage the error.

suspend fun DatabaseReference.delete() {
suspendCoroutine { continuation ->
removeValue()
.addOnSuccessListener {
continuation.resume(Unit)
}
.addOnFailureListener {
continuation.resumeWithException(it)
}
}
}

If you want to read more content like this and support me, don’t forget to check my profile or subscribe here to get an email every time I publish new content.

--

--

Miguel
CodeX

Hi! I’m Miguel and I’m a software engineer. I’ll be writing about programming and whatever comes to my mind.