Cloud Firestore
TrailLog can now tell who is using the app, thanks to Firebase Auth. But every trail is still stored only in Room, on that one device. If the user gets a new phone, or installs TrailLog on a tablet, their hiking history simply isn't there. Cloud Firestore is Google's cloud-hosted, real-time NoSQL document database, and it's the natural next step: it stores the same kind of trail data Room does, but on Google's servers, synced automatically to every device the user signs into.
Firestore vs. Room: Local vs. Cloud, Not a Replacement
It's worth being precise about how this relates to Room Database, because the two solve different problems rather than competing:
| Room | Firestore | |
|---|---|---|
| Where data lives | Only on this device's local SQLite database | Google's cloud, synced to every signed-in device |
| Works offline | Always — it's local by definition | Yes, with an on-device cache, then syncs when back online |
| Survives an uninstall / new phone | No | Yes — tied to the user's account, not the device |
| Best for | Fast, always-available local reads for the UI | Cross-device sync and backup |
A real TrailLog wouldn't necessarily replace Room with Firestore — many production apps keep Room as the fast local cache the UI reads from, and use Firestore to back that data up and sync it in the background. For this lesson, though, we focus on Firestore in isolation so the concepts are clear.
The Collection/Document Data Model
Firestore organizes data into collections of documents. A
document is a single JSON-like record with named fields; a collection is a named group of
documents, similar in spirit to a table of rows, though far more flexible about what fields each
document actually has. For TrailLog, a single trails collection holds one document per
saved trail, and — now that every trail belongs to a signed-in user — each document also stores the
owner's Firebase Auth UID from the previous lesson:
→ a1B2c3D4 (document):
{ ownerUid: "u_9f2k...", name: "Ridge Loop", distanceKm: 8.4, latitude: 34.05, longitude: -118.25, date: "2026-08-10" }→ x9Y8z7W6 (document):
{ ownerUid: "u_9f2k...", name: "Lakeside Trail", distanceKm: 5.1, latitude: 34.10, longitude: -118.30, date: "2026-08-14" }
Storing ownerUid directly on every document is what makes it possible to later ask
Firestore for "just this user's trails," and — as covered further down — it's also the field
Security Rules check to decide who's allowed to read or write a given document.
Writing Data
Saving a new trail is a single call — .add() creates a new document with an
auto-generated ID inside the given collection:
Saving a new trail to Firestore
suspend fun saveTrailToCloud(trail: Trail, ownerUid: String) {
val data = hashMapOf(
"ownerUid" to ownerUid,
"name" to trail.name,
"notes" to trail.notes,
"distanceKm" to trail.distanceKm,
"latitude" to trail.latitude,
"longitude" to trail.longitude,
"date" to trail.date
)
Firebase.firestore.collection("trails")
.add(data)
.await() // same .await() bridge introduced in kotlin-firebase-auth.php
}
Firebase.firestore is the Kotlin extension for the singleton
FirebaseFirestore instance, and .collection("trails") points at the
collection covered above without needing to create it explicitly — Firestore creates a collection
the first time a document is added to it.
Real-Time Listening: addSnapshotListener as a Coroutine Flow
Firestore's real advantage over a one-off network fetch is that it can push updates as they
happen. addSnapshotListener registers a callback that fires immediately with
the current data, and again every single time anything in the query's results changes — on any
device, for any reason. Like the WebSocketListener in
WebSockets and the location callbacks in
Fused Location Provider, this is a callback API, and the
same callbackFlow { } technique from that lesson turns it into a coroutine-friendly
Flow:
Observing this user's trails in real time
fun observeMyTrails(ownerUid: String): Flow<List<Trail>> = callbackFlow {
val registration = Firebase.firestore.collection("trails")
.whereEqualTo("ownerUid", ownerUid)
.orderBy("date")
.addSnapshotListener { snapshot, error ->
if (error != null) {
close(error) // close the Flow with the underlying error
return@addSnapshotListener
}
val trails = snapshot?.documents.orEmpty().mapNotNull {
it.toObject(Trail::class.java)
}
trySend(trails) // push the latest list into the Flow
}
awaitClose {
// runs when the collecting coroutine is cancelled — always detach the listener
registration.remove()
}
}
The shape here is exactly the same one used for OkHttp WebSockets: trySend() forwards
each new snapshot, close() ends the Flow on error, and
awaitClose { } guarantees registration.remove() runs when the collector
goes away — otherwise the listener would keep running (and billing Firestore reads) in the
background forever. Seeing this same pattern work for a WebSocket, a location provider, and now a
Firestore listener is a good sign it's a genuinely general technique for bridging callback APIs
into Flow, not a one-off trick.
Querying with .whereEqualTo() and .orderBy()
The query built above already demonstrates both filtering and ordering:
.whereEqualTo("ownerUid", ownerUid) restricts results to documents belonging to the
current user, and .orderBy("date") returns them oldest-to-newest. These can be
combined and chained freely — Firestore evaluates the whole chain as one query on its servers and
only sends back matching documents, rather than TrailLog downloading every trail in the collection
and filtering client-side.
Security Rules: The Real Enforcement of "Your Trails Only"
Here's a genuinely important point that's easy to miss: the .whereEqualTo("ownerUid", ...)
filter above only asks Firestore for one user's trails — it does nothing to stop a
different, modified client from querying (or writing to) someone else's documents directly. Client-side
query filtering is a convenience, not security. The actual enforcement happens through
Firestore Security Rules, a separate rule set that runs on Google's servers and
checks every single read and write against conditions you define, regardless of what the requesting
app claims to be doing:
match /trails/{trailId} {
allow read, write: if request.auth != null
&& request.auth.uid == resource.data.ownerUid;
}
This rule denies any read or write where the signed-in user's UID doesn't match the document's
ownerUid — enforced server-side, before the data ever reaches the requester. Without
rules like this in place, the sign-in flow from Firebase
Auth only identifies who's asking; it's Security Rules that actually decide what they're
allowed to see.