I want to be able to listen to realtime updates in Firebase DB\'s using Kotlin coroutines in my ViewModel.
The problem is that whenever a new message is created in t
This is working for me:
suspend fun DocumentReference.observe(block: suspend (getNextSnapshot: suspend ()->DocumentSnapshot?)->Unit) {
val channel = Channel<Pair<DocumentSnapshot?, FirebaseFirestoreException?>>(Channel.UNLIMITED)
val listenerRegistration = this.addSnapshotListener { value, error ->
channel.sendBlocking(Pair(value, error))
}
try {
block {
val (value, error) = channel.receive()
if (error != null) {
throw error
}
value
}
}
finally {
channel.close()
listenerRegistration.remove()
}
}
Then you can use it like:
docRef.observe { getNextSnapshot ->
while (true) {
val value = getNextSnapshot() ?: continue
// do whatever you like with the database snapshot
}
}
If the observer block throws an error, or the block finishes, or your coroutine is cancelled, the listener is removed automatically.
What I ended up with is I used Flow which is part of coroutines 1.2.0-alpha-2
return flowViaChannel { channel ->
firestore.collection(path).addSnapshotListener { data, error ->
if (error != null) {
channel.close(error)
} else {
if (data != null) {
val messages = data.toObjects(MessageEntity::class.java)
channel.sendBlocking(messages)
} else {
channel.close(CancellationException("No data received"))
}
}
}
channel.invokeOnClose {
it?.printStackTrace()
}
}
And that's how I observe it in my ViewModel
launch {
messageRepository.observe().collect {
//process
}
}
more on topic https://medium.com/@elizarov/cold-flows-hot-channels-d74769805f9
For Firebase's Firestore database there are two types of calls.
addOnCompleteListener
addSnapshotListener
For one time requests there is an await
extension function provided by the library org.jetbrains.kotlinx:kotlinx-coroutines-play-services:X.X.X
. The function returns results from addOnCompleteListener
.
For the latest version, see the Maven Repository, kotlinx-coroutines-play-services.
Resources
The extension function awaitRealtime
has checks including verifying the state of the continuation
in order to see whether it is in isActive
state. This is important because the function is called when the user's main feed of content is updated either by a lifecycle event, refreshing the feed manually, or removing content from their feed. Without this check there will be a crash.
ExtenstionFuction.kt
data class QueryResponse(val packet: QuerySnapshot?, val error: FirebaseFirestoreException?)
suspend fun Query.awaitRealtime() = suspendCancellableCoroutine<QueryResponse> { continuation ->
addSnapshotListener({ value, error ->
if (error == null && continuation.isActive)
continuation.resume(QueryResponse(value, null))
else if (error != null && continuation.isActive)
continuation.resume(QueryResponse(null, error))
})
}
In order to handle errors the try
/catch
pattern is used.
Repository.kt
object ContentRepository {
fun getMainFeedList(isRealtime: Boolean, timeframe: Timestamp) = flow<Lce<PagedListResult>> {
emit(Loading())
val labeledSet = HashSet<String>()
val user = usersDocument.collection(getInstance().currentUser!!.uid)
syncLabeledContent(user, timeframe, labeledSet, SAVE_COLLECTION, this)
getLoggedInNonRealtimeContent(timeframe, labeledSet, this)
}
// Realtime updates with 'awaitRealtime' used
private suspend fun syncLabeledContent(user: CollectionReference, timeframe: Timestamp,
labeledSet: HashSet<String>, collection: String,
lce: FlowCollector<Lce<PagedListResult>>) {
val response = user.document(COLLECTIONS_DOCUMENT)
.collection(collection)
.orderBy(TIMESTAMP, DESCENDING)
.whereGreaterThanOrEqualTo(TIMESTAMP, timeframe)
.awaitRealtime()
if (response.error == null) {
val contentList = response.packet?.documentChanges?.map { doc ->
doc.document.toObject(Content::class.java).also { content ->
labeledSet.add(content.id)
}
}
database.contentDao().insertContentList(contentList)
} else lce.emit(Error(PagedListResult(null,
"Error retrieving user save_collection: ${response.error?.localizedMessage}")))
}
// One time updates with 'await' used
private suspend fun getLoggedInNonRealtimeContent(timeframe: Timestamp,
labeledSet: HashSet<String>,
lce: FlowCollector<Lce<PagedListResult>>) =
try {
database.contentDao().insertContentList(
contentEnCollection.orderBy(TIMESTAMP, DESCENDING)
.whereGreaterThanOrEqualTo(TIMESTAMP, timeframe).get().await()
.documentChanges
?.map { change -> change.document.toObject(Content::class.java) }
?.filter { content -> !labeledSet.contains(content.id) })
lce.emit(Lce.Content(PagedListResult(queryMainContentList(timeframe), "")))
} catch (error: FirebaseFirestoreException) {
lce.emit(Error(PagedListResult(
null,
CONTENT_LOGGED_IN_NON_REALTIME_ERROR + "${error.localizedMessage}")))
}
}
I have these extension functions, so I can simply get back results from the query as a Flow.
Flow is a Kotlin coroutine construct perfect for this purposes. https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/
@ExperimentalCoroutinesApi
fun CollectionReference.getQuerySnapshotFlow(): Flow<QuerySnapshot?> {
return callbackFlow {
val listenerRegistration =
addSnapshotListener { querySnapshot, firebaseFirestoreException ->
if (firebaseFirestoreException != null) {
cancel(
message = "error fetching collection data at path - $path",
cause = firebaseFirestoreException
)
return@addSnapshotListener
}
offer(querySnapshot)
}
awaitClose {
Timber.d("cancelling the listener on collection at path - $path")
listenerRegistration.remove()
}
}
}
@ExperimentalCoroutinesApi
fun <T> CollectionReference.getDataFlow(mapper: (QuerySnapshot?) -> T): Flow<T> {
return getQuerySnapshotFlow()
.map {
return@map mapper(it)
}
}
The following is an example of how to use the above functions.
@ExperimentalCoroutinesApi
fun getShoppingListItemsFlow(): Flow<List<ShoppingListItem>> {
return FirebaseFirestore.getInstance()
.collection("$COLLECTION_SHOPPING_LIST")
.getDataFlow { querySnapshot ->
querySnapshot?.documents?.map {
getShoppingListItemFromSnapshot(it)
} ?: listOf()
}
}
// Parses the document snapshot to the desired object
fun getShoppingListItemFromSnapshot(documentSnapshot: DocumentSnapshot) : ShoppingListItem {
return documentSnapshot.toObject(ShoppingListItem::class.java)!!
}
And in your ViewModel class, (or your Fragment) make sure you call this from the right scope, so the listener gets removed appropriately when the user moves away from the screen.
viewModelScope.launch {
getShoppingListItemsFlow().collect{
// Show on the view.
}
}