18.
Reading Data from Cloud Firestore
Written by Dean Djermanović
In the previous chapter, you learned how to write data to the Firestore, and how to update or delete data from Firestore by implementing that functionality in the WhatsUp app. You also became familiar with the Firebase console and learned how to use it for managing data.
In this chapter, you’ll continue working on your app. Since now you still don’t have any data on the home screen when you run the app, you’ll focus on implementing reading logic. In the process, you’ll learn how to read data from the Firestore, how to listen for updates in real-time, and how queries work.
Setting up Firebase
If you skipped previous chapters, you need to setup Firebase in order to follow along. Do the following steps:
- Create a project in the Firebase console.
- Enable Google sign-in.
- Set security rules to the test mode to allow everyone read and write access.
- Add google-service.json to both starter and final projects.
To see how to do this, go back to “Chapter 11: Firebase Overview” and “Chapter 12: Introduction to Firebase Realtime Database.”
Be sure to use the starter project from this chapter, by opening the reading-data-from-cloud-firestore folder and its starter project from the projects folder, rather than continuing with the final project you previously worked on. It has a few things added to it, including placeholders for the code to add in this chapter.
Reading data
Like the Realtime Database, Firestore allows to read data once, or to listen for data changes in real-time.
To get the data once, you need to call get() on the collection reference from which you want to read the data or you can also use get() on the document reference, if you need to read data from a specific document. For example, this is how you’d read the data from the posts collection:
database.collection("posts")
.get()
.addOnSuccessListener { result ->
...
}
.addOnFailureListener { exception ->
...
}
Since getting the data is asynchronous, you need to attach a listener that will notify you when the data fetching is complete. It returns the data as a QuerySnapshot. QuerySnapshot is a class that contains the results of a query and can contain QueryDocumentSnapshot objects if they are available. A QueryDocumentSnapshot contains data read from a document in your Firestore database. You’ll see an example of this shortly.
Since you want that your app always has the latest data you won’t fetch the data only once. Instead, you’ll implement a listener, so you can receive events when the data changes.
Listening for data changes
If you don’t have the project open by now, make sure to open it, and head over to CloudFirestoreManager.kt. Add a postsRegistration field like this:
private lateinit var postsRegistration: ListenerRegistration
ListenerRegistration represents a subscription of sorts, for a database reference, when you attach a listener to the reference. You’ll use it to assign a posts listener to it and to remove it when needed, to clean up your code, and to stop receiving changes.
Next, navigate to listenForPostsValueChanges(). Replace the TODO inside the fuction with the following implementation:
private fun listenForPostsValueChanges() {
// 1
postsRegistration = database.collection(POSTS_COLLECTION) // 2
// 3
.addSnapshotListener(EventListener<QuerySnapshot> { value, error ->
// 4
if (error != null || value == null) {
return@EventListener
}
// 5
if (value.isEmpty) {
// 6
postsValues.postValue(emptyList())
} else {
// 7
val posts = ArrayList<Post>()
// 8
for (doc in value) {
// 9
val post = doc.toObject(Post::class.java)
posts.add(post)
}
// 10
postsValues.postValue(posts)
}
})
}
- You assign the listener to
postsRegistration, by attaching anEventListenerto the database collection. - You receive the database collection by calling
database.collection(POSTS_COLLECTION). - You call
addSnapshotListener()on the collection reference which starts listening for the data changes at its location. - When
onEvent()is called you first check if an error occurred by checking if the error argument is notnull, or if the value isnull. If it is you’ll just return from the function, since you cannot consume the event. - Check if the received data actually contains documents - it isn’t empty.
- If the value is empty, it means no posts are available, and you simply communicate it by updating the
postsValueswith anemptyList(). - If there are values, instantiate an
ArrayListto store them. - Iterate through each document in the value snapshot.
- Parse each document as a
Post, and add it toposts. - Update the
postsValueswith parsed posts.
To read the data, you pass in the listener of type EventListener that will be called whenever data changes or if an error occurs. EventListener is a generic interface that is used for any type of event listening, and contains only the onEvent(T value, FirebaseFirestoreException error) function that you’re required to implement. onEvent() will be called with the new value or the error if an error occurred. You get the result data as a QuerySnapshot object. This is the easiest way to communicate data changes or errors, and since it’s generic, it can work for any collection.
Open HomeActivity and navigate to the listenForPostsUpdates function. Replace the TODO inside the function with the following:
private fun listenForPostsUpdates() {
cloudFirestoreManager.onPostsValuesChange()
.observe(this, Observer(::onPostsUpdate))
}
When new data is received you’ll reflect that, by displaying it on the screen.
You also need to remove the listener when you no longer want to receive data change events. To do that, open CloudFirestoreManager.kt and navigate to stopListeningForPostChanges() and replace the function code with the following:
fun stopListeningForPostChanges() = postsRegistration.remove()
By calling remove method on a ListenerRegistration object you remove the listener from the locatin that this listener is assigned to.
Now go back to the HomeActivity and override onStop method and call stopListeningForPostChanges from there, like this:
override fun onStop() {
super.onStop()
cloudFirestoreManager.stopListeningForPostChanges()
}
Build and run your app. You should see the posts now on the home screen:
You can test the real-time updates by deleting the post directly from the console. You should see how the change is reflected in the app almost instantly.
Performing queries
Sometimes, you don’t want to read just the documents of certain collections. Sometimes you need to filter out, match by values, or simply skip a certain amount of documents. To do this, you use database queries. Since you don’t have any nested documents in the posts collection, let’s add something to make it a bit more complex.
Adding comments
There’s one more feature that you have in the Realtime Database version of the WhatsUp app that’s you didn’t add, and that is the ability to add a comment to the post. You’ll add that now and you’ll use that feature to see how the queries are performed.
Open CloudFirestoreManager.kt, once again, and navigate to addComment(). Replace the TODO, inside the function, with the following code:
// 1
val commentReference = database.collection(COMMENTS_COLLECTION).document()
// 2
val comment = HashMap<String, Any>()
// 3
comment[AUTHOR_KEY] = authenticationManager.getCurrentUser()
comment[CONTENT_KEY] = content
comment[POST_ID] = postId
comment[TIMESTAMP_KEY] = getCurrentTime()
// 4
commentReference
.set(comment) // 5
.addOnSuccessListener { onSuccessAction() } // 6
.addOnFailureListener { onFailureAction() } // 7
The logic for adding the comment to the database is exactly the same as with adding posts so you should understand what happens in the code above by now, but to sum it up, here’s what happens:
- Create a new document in Firestore, and store its reference.
- Create a
HashMap<String, Any>, to store the comment data. - Store the data in
comment. - Use the commentReference to communicate the save operation.
- Set the reference value to the new comment.
- In case of a Success, call the
onSuccessAction. - In case something goes wrong - a Failure occured, call the
onFailureAction.
Open PostDetailsActivity.kt, navigate to initializeClickListener(), and replace the TODO inside addCommentButton click listener with a call to addComment():
...
addCommentButton.setOnClickListener {
val comment = commentEditText.text.toString().trim()
if (comment.isNotEmpty()) {
cloudFirestoreManager.addComment(
post.id,
comment,
::onCommentSuccessfullyAdded,
::onCommentAddFailed
)
} else {
showToast(getString(R.string.empty_comment_message))
}
}
This will save the comment to the database on Add Comment button click.
Build and run your app. Tap on any post in the list. Enter some text into the comments EditText and tap the Add Comment button:
Your comment is now saved to the database. Open the database in the console to confirm that. You should see your comment there:
Listening for comments
You can add comments to the database now, but you still can’t read them. Since comments are stored in a separate collection from posts, to read them you’ll need to write a query that returns comments for the specific post. Every comment document has a post_id property that indicates to which post the comment belongs to.
Open CloudFirestoreManager.kt. Add a commentsRegistration field:
private lateinit var commentsRegistration: ListenerRegistration
You’ll use this field to assign the comments listener to it and to remove the listener when needed, just like before.
Next, navigate to listenForPostCommentsValueChanges(). Replace the TODO inside the function with the following:
// 1
commentsRegistration = database.collection(COMMENTS_COLLECTION)
// 2
.whereEqualTo(POST_ID, postId) // 3
// 4
.addSnapshotListener(EventListener<QuerySnapshot> { value, error ->
if (error != null || value == null) {
return@EventListener
}
if (value.isEmpty) {
postsValues.postValue(emptyList())
} else {
val comments = ArrayList<Comment>()
for (doc in value) {
val comment = doc.toObject(Comment::class.java)
comments.add(comment)
}
commentsValues.postValue(comments)
}
})
- As before, you assing a listener to the
commentsreference. - You use
whereEqualTomethod to create the query that filters the documents in the collection that contain the specified field and value in that field. - Pass in the
POST_IDthat represents thepost_idproperty and thepostIdthat represents the value for comparison. This query will only return the documents that belong to the specified post.whereEqualTomethod returns aQuerythat you can read or listen to. - Once again, attach an
EventListenerand parse theComments, if there are any, updating the UI when you’re done.
Next, open PostDetailsActivity.kt and navigate to listenForComments(). Replace the TODO inside the function with the following:
cloudFirestoreManager.onCommentsValuesChange(post.id)
.observe(this, Observer(::onCommentsUpdate))
Here, you start listening for the comments changes for that particular post and when the data changes you update the UI.
Go back to the CloudFirestoreManager.kt class and navigate to stopListeningForCommentsChanges(). Replace the function with the following:
fun stopListeningForCommentsChanges() = commentsRegistration.remove()
Here, you remove the listener from the location that you assigned to the commentsRegistration.
Open PostDetailsActivity.kt class and override onStop(). Remove the comments listener from this method because this is the point where you’re no longer interested in the comments changes:
override fun onStop() {
super.onStop()
cloudFirestoreManager.stopListeningForCommentsChanges()
}
If you build and run the code now, you should see the comment appear, in the post details section of the app.
Deleting comments
One last thing that you need to add is the ability to delete the comments. You’ll delete the comments for the particular posts when that post is deleted.
Open CloudFirestoreManager.kt and navigate to deletePostComments(). Replace the TODO inside the function with the following:
// 1
database.collection(COMMENTS_COLLECTION)
.whereEqualTo(POST_ID, postId)
//2
.get()
//3
.continueWith { task -> task.result?.documents?.forEach { it.reference.delete() } }
- First, get a reference to the comments collection and filter the comments that belong to the specific post.
- Call
get()to retrieve the filtered-by-post comments, asynchronously. - After comments are loaded, delete them from the database, one by one.
continueWith() on a Task instance returns a new Task that will be completed with the result of applying the specified Continuation to this Task. A Continuation is a function that is called to continue execution after completion of a Task. When the comments for the specific posts are fetched, you delete them by traversing the result documents and calling delete method on each document reference. // TODO FPE - Should we try to separate and clarify this more? And how?
Finally, call deletePostComments() from deletePost(), to delete the comments tied to that post, when thte post is deleted:
fun deletePost(key: String, onSuccessAction: () -> Unit, onFailureAction: () -> Unit) {
...
deletePostComments(key)
}
Build and run your app. Open the post that you added a comment to before. You’ll now see that your comment is displayed, like before. Add another comment and you’ll see that it’s displayed on the screen immediately.
Open the database console. Now, delete the posts that you added comments to and observe the database in the console. You’ll notice that comments for that post are deleted, as well.
Working offline
Like Realtime Database, Firestore can also work offline. Cloud Firestore stores a copy of data that your app is using, locally, so that you can have access to the data if the device goes offline. You can perform operations like reading, writing and querying on the local copy. When your device goes back online, Firestore automatically syncs the data with the data that is stored remotely!
Firestores offline persistence is enabled by default for mobile clients. You can test this in your WhatsUp app.
Build and run your app. Add some posts if you don’t have already. You can add some comment to that post as well if you like. Now disconnect the device from the network and kill the process of your app. Start your app again and you’ll notice that your data is still displayed on the screen.
Now add another post. Tap on the floating action button on the home screen and enter some content for the post and tap the Post button. Nothing happens because you only consider post saved when it is saved to the remote database.
Go back to the home screen by tapping the system back button. You’ll see your post that you added while offline is displayed on the home screen. This is because it was saved to the local cache. If you open the console and look into the database you won’t see that post in the database.
Now connect your device back to the network. You’ll get a toast message on the device that the post is saved an now you can see your post in the remote database.
If you don’t want to have the offline feature enabled, you can disable it when initializing Cloud Firestore.
Check the official documentation to learn more about offline support.
Other features
Cloud Firestore has many other features. You’ll go through some of them next.
Ordering and limiting
You’ve already seen how you can specify which documents you want to fetch from the collection by using whereEqualTo(). But there’s much more you can do, on top of the whereEqualTo():
- You can use
orderBy()on the collection reference to sort the data by the specified field. By default, the documents are sorted in ascending order by document ID. - You can use
limit()on the collection reference to only return up to the specified number of documents. - You can also combine all of the
wheremethods for filtering withlimit()andorderBy().
Pagination
You can have a lot of data stored in your database, but you probably don’t need all of the data all the time. Pagination allows you to split your database data into chunks so that you don’t need to fetch all of it at once.
Firestore provides you with the pagination feature, that works in a way where you don’t need to execute one large query, but instead multiple smaller queries sequentially. Firestores library has some useful methods that you can use to divide your query into smaller queries, like startAt(), startAfter(), endAt() or endBefore().
Check the official documentation to learn more about pagination.
Indexing
To ensure good performance for every query, Firestore requires an index. Firestore automatically creates indices for the basic queries for you.
Check the official documentation to learn more about how to add indexing manually and how it works.
Key points
-
Firestore allows to read data once or to listen for data changes in real-time.
-
To get the data once, you would need to use
getmethod on the collection reference. -
ListenerRegistrationinterface represents a Firestore subscription listener. -
You can call
addSnapshotListener()on a collection reference to start listening for data changes at a specific location. -
Queries are used to get only a subset of the documents within a collection.
-
Cloud Firestore stores a copy of data that your app is using, locally, so that you can access the data, if the device goes offline.
-
You can also use
orderBy()andlimit(), on the collection reference, to get only specific documents from a collection. -
Pagination allows you to split your database data into chunks so that you don’t need to fetch all your data at once.
-
To ensure good performance for every query, Firestore requires an index, when creating them.
Where to go from here?
You covered a lot in this chapter. You learned how to read data from Firestore and listen for data changes in real-time. You also learned what queries are and how to use them only to fetch specific documents from a collection.
To learn more about those features, you can check out the official documentation.
WhatsUp app is now complete, but it has one big flaw. Anyone can read and write the data to the database. In “Chapter 19: Securing data in Cloud Firestore” you’ll learn how to secure the data in the database and to restrict access to the data.