1. Overview
Goals
In this codelab you will build a restaurant recommendation app on Android backed by Cloud Firestore. You will learn how to:
- Read and write data to Firestore from an Android app
- Listen to changes in Firestore data in realtime
- Use Firebase Authentication and security rules to secure Firestore data
- Write complex Firestore queries
Prerequisites
Before starting this codelab make sure you have:
- Android Studio
Flamingo
or newer
- An Android emulator with API
19
or higher
- Node.js version
16
or higher
- Java version
17
or higher
2. Create a Firebase project
- Sign into the
Firebase console
with your Google account.
- In the
Firebase console
, click
Add project
.
- As shown in the screen capture below, enter a name for your Firebase project (for example, "Friendly Eats"), and click
Continue
.
- You may be asked to enable Google Analytics, for the purposes of this codelab your selection does not matter.
- After a minute or so, your Firebase project will be ready. Click
Continue
.
3. Set up the sample project
Download the code
Run the following command to clone the sample code for this codelab. This will create a folder called
friendlyeats-android
on your machine:
$ git clone https://github.com/firebase/friendlyeats-android
If you don't have git on your machine, you can also download the code directly from GitHub.
Add Firebase configuration
- In the
Firebase console
, select
Project Overview
in the left nav. Click the
Android
button to select the platform. When prompted for a package name use
com.google.firebase.example.fireeats
- Click
Register App
and follow the instructions to download the
google-services.json
file, and move it into the
app/
folder of the code you just downloaded. Then click
Next
.
Import the project
Open Android Studio. Click
File
>
New
>
Import Project
and select the
friendlyeats-android
folder.
4. Set up the Firebase Emulators
In this codelab you'll use the
Firebase Emulator Suite
to locally emulate Cloud Firestore and other Firebase services. This provides a safe, fast, and no-cost local development environment to build your app.
Install the Firebase CLI
First you will need to install the
Firebase CLI
. If you are using macOS or Linux, you can run the following cURL command:
curl -sL https://firebase.tools | bash
If you are using Windows, read the
installation instructions
to get a standalone binary or to install via
npm
.
Once you've installed the CLI, running
firebase --version
should report a version of
9.0.0
or higher:
$ firebase --version
9.0.0
Log In
Run
firebase login
to connect the CLI to your Google account. This will open a new browser window to complete the login process. Make sure to choose the same account you used when creating your Firebase project earlier.
Link your project
From within the
friendlyeats-android
folder run
firebase use --add
to connect your local project to your Firebase project. Follow the prompts to select the project you created earlier and if asked to choose an alias enter
default
.
5. Run the app
Now it's time to run the Firebase Emulator Suite and the FriendlyEats Android app for the first time.
Run the emulators
In your terminal from within the
friendlyeats-android
directory run
firebase emulators:start
to start up the Firebase Emulators. You should see logs like this:
$ firebase emulators:start
i emulators: Starting emulators: auth, firestore
i firestore: Firestore Emulator logging to firestore-debug.log
i ui: Emulator UI logging to ui-debug.log
┌─────────────────────────────────────────────────────────────┐
│ ? All emulators ready! It is now safe to connect your app. │
│ i View Emulator UI at http://localhost:4000 │
└─────────────────────────────────────────────────────────────┘
┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator │ Host:Port │ View in Emulator UI │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ localhost:9099 │ http://localhost:4000/auth │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore │ localhost:8080 │ http://localhost:4000/firestore │
└────────────────┴────────────────┴─────────────────────────────────┘
Emulator Hub running at localhost:4400
Other reserved ports: 4500
Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files.
You now have a complete local development environment running on your machine! Make sure to leave this command running for the rest of the codelab, your Android app will need to connect to the emulators.
Connect app to the Emulators
Open the files
util/FirestoreInitializer.kt
and
util/AuthInitializer.kt
in Android Studio. These files contain the logic to connect the Firebase SDKs to the local emulators running on your machine, upon application startup.
On the
create()
method of the
FirestoreInitializer
class, examine this piece of code:
// Use emulators only in debug builds
if (BuildConfig.DEBUG) {
firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT)
}
We are using
BuildConfig
to make sure we only connect to the emulators when our app is running in
debug
mode. When we compile the app in
release
mode this condition will be false.
We can see that it is using the
useEmulator(host, port)
method to connect the Firebase SDK to the local Firestore emulator. Throughout the app we will use
FirebaseUtil.getFirestore()
to access this instance of
FirebaseFirestore
so we are sure that we're always connecting to the Firestore emulator when running in
debug
mode.
Run the app
If you have added the
google-services.json
file properly, the project should now compile. In Android Studio click
Build
>
Rebuild Project
and ensure that there are no remaining errors.
In Android Studio
Run
the app on your Android emulator. At first you will be presented with a "Sign in" screen. You can use any email and password to sign into the app. This sign in process is connecting to the Firebase Authentication emulator, so no real credentials are being transmitted.
Now open the Emulators UI by navigating to
http://localhost:4000
in your web browser. Then click on the
Authentication
tab and you should see the account you just created:
Once you have completed the sign in process you should see the app home screen:
Soon we will add some data to populate the home screen.
6. Write data to Firestore
In this section we will write some data to Firestore so that we can populate the currently empty home screen.
The main model object in our app is a restaurant (see
model/Restaurant.kt
). Firestore data is split into documents, collections, and subcollections. We will store each restaurant as a document in a top-level collection called
"restaurants"
. To learn more about the Firestore data model, read about documents and collections in
the documentation
.
For demonstration purposes, we will add functionality in the app to create ten random restaurants when we click the "Add Random Items" button in the overflow menu. Open the file
MainFragment.kt
and replace the content in the
onAddItemsClicked()
method with:
private fun onAddItemsClicked() {
val restaurantsRef = firestore.collection("restaurants")
for (i in 0..9) {
// Create random restaurant / ratings
val randomRestaurant = RestaurantUtil.getRandom(requireContext())
// Add restaurant
restaurantsRef.add(randomRestaurant)
}
}
There are a few important things to note about the code above:
- We started by getting a reference to the
"restaurants"
collection. Collections are created implicitly when documents are added, so there was no need to create the collection before writing data.
- Documents can be created using Kotlin data classes, which we use to create each Restaurant doc.
- The
add()
method adds a document to a collection with an auto-generated ID, so we did not need to specify a unique ID for each Restaurant.
Now run the app again and click the "Add Random Items" button in the overflow menu (at the top right corner) to invoke the code you just wrote:
Now open the Emulators UI by navigating to
http://localhost:4000
in your web browser. Then click on the
Firestore
tab and you should see the data you just added:
This data is 100% local to your machine. In fact, your real project doesn't even contain a Firestore database yet! This means it's safe to experiment with modifying and deleting this data without consequence.
Congratulations, you just wrote data to Firestore! In the next step we'll learn how to display this data in the app.
7. Display data from Firestore
In this step we will learn how to retrieve data from Firestore and display it in our app. The first step to reading data from Firestore is to create a
Query
. Open the file
MainFragment.kt
and add the following code to the beginning of the
onViewCreated()
method:
// Firestore
firestore = Firebase.firestore
// Get the 50 highest rated restaurants
query = firestore.collection("restaurants")
.orderBy("avgRating", Query.Direction.DESCENDING)
.limit(LIMIT.toLong())
Now we want to listen to the query, so that we get all matching documents and are notified of future updates in real time. Because our eventual goal is to bind this data to a
RecyclerView
, we need to create a
RecyclerView.Adapter
class to listen to the data.
Open the
FirestoreAdapter
class, which has been partially implemented already. First, let's make the adapter implement
EventListener
and define the
onEvent
function so that it can receive updates to a Firestore query:
abstract class FirestoreAdapter<VH : RecyclerView.ViewHolder>(private var query: Query?) :
RecyclerView.Adapter<VH>(),
EventListener<QuerySnapshot> { // Add this implements
// ...
// Add this method
override fun onEvent(documentSnapshots: QuerySnapshot?, e: FirebaseFirestoreException?) {
// Handle errors
if (e != null) {
Log.w(TAG, "onEvent:error", e)
return
}
// Dispatch the event
if (documentSnapshots != null) {
for (change in documentSnapshots.documentChanges) {
// snapshot of the changed document
when (change.type) {
DocumentChange.Type.ADDED -> {
// TODO: handle document added
}
DocumentChange.Type.MODIFIED -> {
// TODO: handle document changed
}
DocumentChange.Type.REMOVED -> {
// TODO: handle document removed
}
}
}
}
onDataChanged()
}
// ...
}
On initial load the listener will receive one
ADDED
event for each new document. As the result set of the query changes over time the listener will receive more events containing the changes. Now let's finish implementing the listener. First add three new methods:
onDocumentAdded
,
onDocumentModified
, and
onDocumentRemoved
:
private fun onDocumentAdded(change: DocumentChange) {
snapshots.add(change.newIndex, change.document)
notifyItemInserted(change.newIndex)
}
private fun onDocumentModified(change: DocumentChange) {
if (change.oldIndex == change.newIndex) {
// Item changed but remained in same position
snapshots[change.oldIndex] = change.document
notifyItemChanged(change.oldIndex)
} else {
// Item changed and changed position
snapshots.removeAt(change.oldIndex)
snapshots.add(change.newIndex, change.document)
notifyItemMoved(change.oldIndex, change.newIndex)
}
}
private fun onDocumentRemoved(change: DocumentChange) {
snapshots.removeAt(change.oldIndex)
notifyItemRemoved(change.oldIndex)
}
Then call these new methods from
onEvent
:
override fun onEvent(documentSnapshots: QuerySnapshot?, e: FirebaseFirestoreException?) {
// Handle errors
if (e != null) {
Log.w(TAG, "onEvent:error", e)
return
}
// Dispatch the event
if (documentSnapshots != null) {
for (change in documentSnapshots.documentChanges) {
// snapshot of the changed document
when (change.type) {
DocumentChange.Type.ADDED -> {
onDocumentAdded(change) // Add this line
}
DocumentChange.Type.MODIFIED -> {
onDocumentModified(change) // Add this line
}
DocumentChange.Type.REMOVED -> {
onDocumentRemoved(change) // Add this line
}
}
}
}
onDataChanged()
}
Finally implement the
startListening()
method to attach the listener:
fun startListening() {
if (registration == null) {
registration = query.addSnapshotListener(this)
}
}
Now the app is fully configured to read data from Firestore.
Run
the app again and you should see the restaurants you added in the previous step:
Now go back to the Emulator UI in your browser and edit one of the restaurant names. You should see it change in the app almost instantly!
8. Sort and filter data
The app currently displays the top-rated restaurants across the entire collection, but in a real restaurant app the user would want to sort and filter the data. For example the app should be able to show "Top seafood restaurants in Philadelphia" or "Least expensive pizza".
Clicking white bar at the top of the app brings up a filters dialog. In this section we'll use Firestore queries to make this dialog work:
Let's edit the
onFilter()
method of
MainFragment.kt
. This method accepts a
Filters
object which is a helper object we created to capture the output of the filters dialog. We will change this method to construct a query from the filters:
override fun onFilter(filters: Filters) {
// Construct query basic query
var query: Query = firestore.collection("restaurants")
// Category (equality filter)
if (filters.hasCategory()) {
query = query.whereEqualTo(Restaurant.FIELD_CATEGORY, filters.category)
}
// City (equality filter)
if (filters.hasCity()) {
query = query.whereEqualTo(Restaurant.FIELD_CITY, filters.city)
}
// Price (equality filter)
if (filters.hasPrice()) {
query = query.whereEqualTo(Restaurant.FIELD_PRICE, filters.price)
}
// Sort by (orderBy with direction)
if (filters.hasSortBy()) {
query = query.orderBy(filters.sortBy.toString(), filters.sortDirection)
}
// Limit items
query = query.limit(LIMIT.toLong())
// Update the query
adapter.setQuery(query)
// Set header
binding.textCurrentSearch.text = HtmlCompat.fromHtml(
filters.getSearchDescription(requireContext()),
HtmlCompat.FROM_HTML_MODE_LEGACY
)
binding.textCurrentSortBy.text = filters.getOrderDescription(requireContext())
// Save filters
viewModel.filters = filters
}
In the snippet above we build a
Query
object by attaching
where
and
orderBy
clauses to match the given filters.
Run
the app again and select the following filter to show the most popular low-price restaurants:
You should now see a filtered list of restaurants containing only low-price options:
If you've made it this far, you have now built a fully functioning restaurant recommendation viewing app on Firestore! You can now sort and filter restaurants in real time. In the next few sections we'll add reviews to the restaurants and add security rules to the app.
9. Organize data in subcollections
In this section we'll add ratings to the app so users can review their favorite (or least favorite) restaurants.
Collections and subcollections
So far we have stored all restaurant data in a top-level collection called "restaurants". When a user rates a restaurant we want to add a new
Rating
object to the restaurants. For this task we will use a subcollection. You can think of a subcollection as a collection that is attached to a document. So each restaurant document will have a ratings subcollection full of rating documents. Subcollections help organize data without bloating our documents or requiring complex queries.
To access a subcollection, call
.collection()
on the parent document:
val subRef = firestore.collection("restaurants")
.document("abc123")
.collection("ratings")
You can access and query a subcollection just like with a top-level collection, there are no size limitations or performance changes. You can read more about the Firestore data model
here
.
Writing data in a transaction
Adding a
Rating
to the proper subcollection only requires calling
.add()
, but we also need to update the
Restaurant
object's average rating and number of ratings to reflect the new data. If we use separate operations to make these two changes there are a number of race conditions that could result in stale or incorrect data.
To ensure that ratings are added properly, we will use a transaction to add ratings to a restaurant. This transaction will perform a few actions:
- Read the restaurant's current rating and calculate the new one
- Add the rating to the subcollection
- Update the restaurant's average rating and number of ratings
Open
RestaurantDetailFragment.kt
and implement the
addRating
function:
private fun addRating(restaurantRef: DocumentReference, rating: Rating): Task<Void> {
// Create reference for new rating, for use inside the transaction
val ratingRef = restaurantRef.collection("ratings").document()
// In a transaction, add the new rating and update the aggregate totals
return firestore.runTransaction { transaction ->
val restaurant = transaction.get(restaurantRef).toObject<Restaurant>()
?: throw Exception("Restaurant not found at ${restaurantRef.path}")
// Compute new number of ratings
val newNumRatings = restaurant.numRatings + 1
// Compute new average rating
val oldRatingTotal = restaurant.avgRating * restaurant.numRatings
val newAvgRating = (oldRatingTotal + rating.rating) / newNumRatings
// Set new restaurant info
restaurant.numRatings = newNumRatings
restaurant.avgRating = newAvgRating
// Commit to Firestore
transaction.set(restaurantRef, restaurant)
transaction.set(ratingRef, rating)
null
}
}
The
addRating()
function returns a
Task
representing the entire transaction. In the
onRating()
function listeners are added to the task to respond to the result of the transaction.
Now
Run
the app again and click on one of the restaurants, which should bring up the restaurant detail screen. Click the
+
button to start adding a review. Add a review by picking a number of stars and entering some text.
Hitting
Submit
will kick off the transaction. When the transaction completes, you will see your review displayed below and an update to the restaurant's review count:
Congrats! You now have a social, local, mobile restaurant review app built on Cloud Firestore. I hear those are very popular these days.
10. Secure your data
So far we have not considered the security of this application. How do we know that users can only read and write the correct own data? Firestore databases are secured by a configuration file called
Security Rules
.
Open the
firestore.rules
file, you should see the following:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
//
// WARNING: These rules are insecure! We will replace them with
// more secure rules later in the codelab
//
allow read, write: if request.auth != null;
}
}
}
Let's change these rules to prevent unwanted data acesss or changes, open the
firestore.rules
file and replace the content with the following:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Determine if the value of the field "key" is the same
// before and after the request.
function isUnchanged(key) {
return (key in resource.data)
&& (key in request.resource.data)
&& (resource.data[key] == request.resource.data[key]);
}
// Restaurants
match /restaurants/{restaurantId} {
// Any signed-in user can read
allow read: if request.auth != null;
// Any signed-in user can create
// WARNING: this rule is for demo purposes only!
allow create: if request.auth != null;
// Updates are allowed if no fields are added and name is unchanged
allow update: if request.auth != null
&& (request.resource.data.keys() == resource.data.keys())
&& isUnchanged("name");
// Deletes are not allowed.
// Note: this is the default, there is no need to explicitly state this.
allow delete: if false;
// Ratings
match /ratings/{ratingId} {
// Any signed-in user can read
allow read: if request.auth != null;
// Any signed-in user can create if their uid matches the document
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid;
// Deletes and updates are not allowed (default)
allow update, delete: if false;
}
}
}
}
These rules restrict access to ensure that clients only make safe changes. For example updates to a restaurant document can only change the ratings, not the name or any other immutable data. Ratings can only be created if the user ID matches the signed-in user, which prevents spoofing.
To read more about Security Rules, visit
the documentation
.
11. Conclusion
You have now created a fully-featured app on top of Firestore. You learned about the most important Firestore features including:
- Documents and collections
- Reading and writing data
- Sorting and filtering with queries
- Subcollections
- Transactions
Learn More
To keep learning about Firestore, here are some good places to get started:
The restaurant app in this codelab was based on the "Friendly Eats" example application. You can browse the source code for that app
here
.
Optional: Deploy to production
So far this app has only used the Firebase Emulator Suite. If you want to learn how to deploy this app to a real Firebase project, continue on to the next step.
12. (Optional) Deploy your app
So far this app has been entirely local, all of the data is contained in the Firebase Emulator Suite. In this section you will learn how to configure your Firebase project so that this app will work in production.
Firebase Authentication
In the Firebase console go to the
Authentication
section and click
Get started
. Navigate to the
Sign-in method
tab and select the
Email/Password
option from
Native providers
.
Enable the
Email/Password
sign-in method and click
Save
.
Firestore
Create database
Navigate to the
Firestore Database
section of the console and click
Create Database
:
- When prompted about Security Rules choose to start in
Production Mode
, we'll update those rules soon.
- Choose the database location that you'd like to use for your app. Note that selecting a database location is a
permanent
decision and to change it you will have to create a new project. For more information on choosing a project location, see the
documentation
.
Deploy Rules
To deploy the Security Rules you wrote earlier, run the following command in the codelab directory:
$ firebase deploy --only firestore:rules
This will deploy the contents of
firestore.rules
to your project, which you can confirm by navigating to the
Rules
tab in the console.
Deploy Indexes
The FriendlyEats app has complex sorting and filtering which requires a number of custom compound indexes. These can be created by hand in the Firebase console but it is simpler to write their definitions in the
firestore.indexes.json
file and deploy them using the Firebase CLI.
If you open the
firestore.indexes.json
file you will see that the required indexes have already been provided:
{
"indexes": [
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "city", "mode": "ASCENDING" },
{ "fieldPath": "avgRating", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "category", "mode": "ASCENDING" },
{ "fieldPath": "avgRating", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "price", "mode": "ASCENDING" },
{ "fieldPath": "avgRating", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "city", "mode": "ASCENDING" },
{ "fieldPath": "numRatings", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "category", "mode": "ASCENDING" },
{ "fieldPath": "numRatings", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "price", "mode": "ASCENDING" },
{ "fieldPath": "numRatings", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "city", "mode": "ASCENDING" },
{ "fieldPath": "price", "mode": "ASCENDING" }
]
},
{
"collectionId": "restaurants",
"fields": [
{ "fieldPath": "category", "mode": "ASCENDING" },
{ "fieldPath": "price", "mode": "ASCENDING" }
]
}
],
"fieldOverrides": []
}
To deploy these indexes run the following command:
$ firebase deploy --only firestore:indexes
Note that index creation is not instantaneous, you can monitor the progress in the Firebase console.
In the
util/FirestoreInitializer.kt
and
util/AuthInitializer.kt
files we configured the Firebase SDK to connect to the emulators when in debug mode:
override fun create(context: Context): FirebaseFirestore {
val firestore = Firebase.firestore
// Use emulators only in debug builds
if (BuildConfig.DEBUG) {
firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT)
}
return firestore
}
If you would like to test your app with your real Firebase project you can either:
- Build the app in release mode and run it on a device.
- Temporarily replace
BuildConfig.DEBUG
with
false
and run the app again.
Note that you may need to
Sign Out
of the app and sign in again in order to properly connect to production.