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:

RoomFirestore
Where data livesOnly on this device's local SQLite databaseGoogle's cloud, synced to every signed-in device
Works offlineAlways — it's local by definitionYes, with an on-device cache, then syncs when back online
Survives an uninstall / new phoneNoYes — tied to the user's account, not the device
Best forFast, always-available local reads for the UICross-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:

trails/ (collection)
  → 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.

Note: These are correct, working Firestore API calls, but this website's own project doesn't run a live Android Studio build or a real Firestore database, so nothing here actually writes or syncs data on the page itself.

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.

Up next: Trails are safely stored and synced. Cloud Messaging (FCM) lets TrailLog notify a user the moment something relevant happens — like a friend logging a new hike.