-
Notifications
You must be signed in to change notification settings - Fork 0
Offline‐First Architecture & Synchronization
In our application, Offline Support is not an afterthought or a "cache"; it is the primary way the app functions. This document outlines the architectural rules ensuring the app remains responsive and consistent, regardless of network connectivity.
To provide a seamless user experience, the UI never observes the network directly. Instead, we follow a strict unidirectional data flow:
- Read: The UI observes the Local Database (Room).
- Write: User actions modify the Local Database first.
- Sync: A background process synchronizes these changes with the Cloud (Firestore).
This ensures that when a user creates a group or adds an expense, the UI updates instantly, even if the device is in Airplane Mode.
When creating new data (e.g., a Group or Expense), we cannot rely on the server to generate IDs or timestamps, as this would cause items to be missing or unsortable until a sync occurs.
We generate unique identifiers (UUIDs) on the client side before saving. This guarantees that the object exists with a known ID immediately.
Correct Implementation:
val expenseId = UUID.randomUUID().toString()
val expense = expense.copy(id = expenseId)
Why: If we let Firestore generate the ID (.add()), we would have a temporary local object without an ID, and a future cloud object with a different ID, leading to duplicates during sync.
Timestamps (creation dates) and attribution (User IDs) must be populated locally.
-
Timestamps: Use
System.currentTimeMillis()immediately. Do not rely on FirestoreFieldValue.serverTimestamp()for the local copy, or the item will appear at the wrong position (or not at all) in sorted lists. -
User ID: Inject the
AuthenticationServiceinto your Repository to fetch the current user's ID synchronously.
The repository method should look like this:
- Prepare: Create the object with a local UUID and timestamp.
-
Local Commit: Save to Room. The UI updates instantly via the
Flow. - Cloud Sync: Launch a background coroutine to upload the data.
// Example Repository Implementation
override suspend fun addExpense(expense: Expense) {
// 1. Prepare
val finalExpense = expense.copy(
id = UUID.randomUUID().toString(),
createdAt = System.currentTimeMillis()
)
// 2. Local Commit (UI updates now)
localDataSource.saveExpense(finalExpense)
// 3. Cloud Sync (Background)
scope.launch {
cloudDataSource.upsertExpense(finalExpense)
}
}
When fetching data from the cloud, we must ensure we don't accidentally overwrite newer local changes or create duplicates.
Since we generated the ID locally, our Cloud Data Source must use Upsert logic (set with the specific ID), not add.
-
Cloud:
.document(id).set(data) -
Local:
@Insert(onConflict = OnConflictStrategy.REPLACE)
A common "flicker" bug occurs when the app downloads an old list from the cloud and overwrites a list containing a brand-new local item.
To prevent this:
- Use
OnConflictStrategy.REPLACEwhen saving synced data. - Do not delete the entire local table before inserting cloud data. Only insert/update the items returned from the cloud. This leaves your unsynced local items intact.
While we are Offline-First, this is a multi-user, multi-device app. Changes made by one user (or on one device) must eventually propagate to all other users/devices that share the same data. We achieve this using Firestore Snapshot Listeners that feed into Room, keeping the local-first flow intact.
The Repository's get*Flow() method subscribes to a real-time Firestore snapshotListener via onStart. This listener fires whenever any user adds, modifies, or deletes data in the watched collection. Each snapshot represents the complete authoritative state of the collection at that moment.
// Repository
override fun getGroupExpensesFlow(groupId: String): Flow<List<Expense>> {
return localDataSource.getExpensesByGroupIdFlow(groupId) // UI observes Room
.onStart {
syncScope.launch {
subscribeToCloudChanges(groupId) // Persistent Firestore listener
}
}
}
private suspend fun subscribeToCloudChanges(groupId: String) {
cloudDataSource.getExpensesByGroupIdFlow(groupId)
.collect { remoteExpenses ->
// Atomic replace: delete stale + insert fresh in a @Transaction
localDataSource.replaceExpensesForGroup(groupId, remoteExpenses)
}
}How it works:
- The UI subscribes to the Room Flow (instant, offline data).
-
onStartlaunches a persistent Firestore snapshot listener in a background scope. - When the listener fires (new data from any user/device), the Repository atomically replaces the local data for that scope using a Room
@Transaction. - The Room Flow re-emits automatically, updating the UI in near real-time.
Because each Firestore snapshot represents the complete current state, we use an atomic delete + insert strategy in Room:
// Room DAO
@Transaction
open suspend fun replaceExpensesForGroup(groupId: String, expenses: List<ExpenseEntity>) {
deleteByGroupId(groupId)
insertAll(expenses)
}This handles all cases in a single operation:
- Additions by other users → new items appear locally.
- Deletions by other users → stale items are removed locally.
- Modifications by other users → items are updated locally.
⚠️ ThisreplaceAllstrategy is safe here because the data comes from the authoritative Firestore snapshot, not a partial sync. This is distinct from the one-shot sync (Section "Handling Race Conditions" above), where we use upsert-only to avoid overwriting unsynced local changes.
Firestore does NOT automatically delete subcollections when a parent document is deleted. If your real-time listener watches a subcollection (e.g., group_members) to determine data membership, you MUST explicitly delete those subcollection documents before deleting the parent.
Example: Group Deletion
The group real-time listener watches group_members collectionGroup to know which groups the user belongs to. If we only delete the group document, the orphaned member documents remain, and the listener on other devices never fires — other users continue to see the deleted group.
// ❌ BAD - Only deletes parent, orphans subcollection
override suspend fun deleteGroup(groupId: String) {
firestore.collection("groups").document(groupId).delete().await()
// group_members subcollection still exists → other devices still "see" this group
}
// ✅ GOOD - Deletes subcollection FIRST, then parent
override suspend fun deleteGroup(groupId: String) {
// 1. Delete all member documents → triggers listener on other devices
val membersCollection = firestore.collection("groups/$groupId/members")
val memberDocs = membersCollection.get().await()
memberDocs.documents.forEach { doc ->
doc.reference.delete().await()
}
// 2. Delete the group document itself
firestore.collection("groups").document(groupId).delete().await()
}Rule: When deleting a document that has subcollections used by real-time listeners, always delete the subcollection documents first so the listener fires and propagates the deletion to all devices.
The Firestore CloudDataSource implementations use a cache-first strategy inside the snapshot listener callback:
- Cache hit: Load documents from Firestore's local cache (instant, no network).
- Cache miss: Fall back to server fetch for any documents not yet cached.
This minimizes network round-trips while ensuring data completeness.
When implementing a new feature, verify these points:
- No Network Block: Does the repository save to Room before making any network call?
- Local IDs: Is the ID generated using
UUIDin the Repository (or UseCase)? - Local Dates: Is
System.currentTimeMillis()used for the creation date? - Conflict Resolution: Does the DAO use
OnConflictStrategy.REPLACE? - No Blind Deletes: Ensure the sync process doesn't wipe unsynced local items.
- Real-Time Listener: Does the Repository subscribe to a Firestore snapshot listener via
onStartfor multi-user/multi-device sync? - Atomic Reconciliation: Does the local data source use
@Transaction(delete + insert) when reconciling snapshot data? - Subcollection Cleanup: When deleting a parent document, are subcollection documents deleted first to trigger listeners on other devices?