-
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.
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.