| name | offline-first |
| description | Offline-first architecture patterns for Petit Android app. Use when: implementing data persistence, designing Room database operations, working with sync status tracking, building cache strategies, implementing WorkManager background tasks, designing data flows that must work without network, handling export/import, planning future Firebase/Firestore cloud sync. Covers Room as source of truth, soft delete, SyncStatus, WorkManager, DataStore. |
Offline-First — Petit Android
When to Use
- Implementing any data read/write operation
- Designing data flow for new features
- Working with Room database
- Adding background processing (WorkManager)
- Planning future Firebase/Firestore cloud sync integration
- Implementing export/import functionality
- Handling data conflicts
Core Principle
The app MUST be fully functional without any network connection.
Room database is the single source of truth. The UI always reads from Room.
Network operations (future Firebase/Firestore sync) are asynchronous, background, and never block the UI.
Architecture
┌──────────────┐
│ UI Layer │ ← Always reads from Room via Flow
│ (Composable) │
└──────┬───────┘
│ collectAsStateWithLifecycle()
┌──────┴───────┐
│ ViewModel │ ← Exposes StateFlow<UiState>
└──────┬───────┘
│ Repository.getAll(): Flow<List<T>>
┌──────┴───────┐
│ Repository │ ← Maps Entity ↔ Domain Model
└──────┬───────┘
│ DAO.getAll(): Flow<List<Entity>>
┌──────┴───────┐
│ Room DAO │ ← Single source of truth
└──────┬───────┘
│
┌──────┴───────┐
│ Room DB │ ← SQLite on device
└──────────────┘
┌────────────────────┐
│ WorkManager │ ← Background sync via Firebase (future)
│ (sync/backup) │ ← Reminders (current)
└────────────────────┘
Data Flow Rules
Rule 1: UI Reads ONLY from Room
@HiltViewModel
class CatListViewModel @Inject constructor(
private val catRepository: CatRepository,
) : ViewModel() {
val cats: StateFlow<List<Cat>> = catRepository.getAllCats()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
class CatListViewModel(private val api: CatApi) : ViewModel() {
fun loadCats() {
viewModelScope.launch {
_cats.value = api.getCats()
}
}
}
Rule 2: Writes Go to Room First
suspend fun insertCat(cat: Cat) {
val entity = cat.toEntity().copy(
syncStatus = "LOCAL_ONLY",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
)
catDao.insertCat(entity)
}
suspend fun insertCat(cat: Cat) {
api.createCat(cat)
catDao.insertCat(cat.toEntity())
}
Rule 3: Flow-Based Reactivity
@Query("SELECT * FROM cats WHERE deletedAt IS NULL ORDER BY name")
fun getAllCats(): Flow<List<CatEntity>>
fun getAllCats(): List<CatEntity>
Soft Delete Pattern
Never hard-delete data. All deletions are logical (soft delete) to support:
- Undo operations
- Future sync reconciliation
- Conflict resolution
Implementation
@Entity(tableName = "cats")
data class CatEntity(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val name: String,
val deletedAt: Long? = null,
val syncStatus: String = "LOCAL_ONLY"
)
@Dao
interface CatDao {
@Query("SELECT * FROM cats WHERE deletedAt IS NULL ORDER BY name")
fun getAllCats(): Flow<List<CatEntity>>
@Query("UPDATE cats SET deletedAt = :timestamp, updatedAt = :timestamp WHERE id = :id")
suspend fun softDeleteCat(id: String, timestamp: Long = System.currentTimeMillis())
@Query("DELETE FROM cats")
suspend fun deleteAllCats()
}
override suspend fun softDeleteCat(id: String) {
catDao.softDeleteCat(id)
}
Rules:
- Every
SELECT query includes WHERE deletedAt IS NULL
- Soft delete sets
deletedAt = System.currentTimeMillis()
- Hard delete (
DELETE FROM) only in "delete all data" feature
- Cascade delete:
ForeignKey.CASCADE handles child records automatically
Sync Status Tracking
Every entity has a syncStatus field for future Firebase/Firestore cloud sync:
enum class SyncStatus {
LOCAL_ONLY,
PENDING_SYNC,
SYNCED,
CONFLICT
}
State Transitions
┌────────────────┐
Create → │ LOCAL_ONLY │
└───────┬────────┘
│ (sync enabled + push success)
┌───────┴────────┐
│ SYNCED │
└───────┬────────┘
│ (local edit)
┌───────┴────────┐
│ PENDING_SYNC │
└───────┬────────┘
│ (push success) │ (server also changed)
↓ ↓
SYNCED CONFLICT
Rules:
- New records start as
LOCAL_ONLY
- Local edits change status to
PENDING_SYNC
- Successful sync changes status to
SYNCED
- Conflict detection uses
updatedAt timestamps (last-write-wins)
- Currently (Phase 1): All records stay
LOCAL_ONLY
WorkManager for Background Tasks
Current Usage: Reminders
class ReminderWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val reminderRepository: ReminderRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val reminderId = inputData.getString("reminderId") ?: return Result.failure()
val reminder = reminderRepository.getReminderById(reminderId) ?: return Result.failure()
showNotification(reminder)
if (reminder.repeatInterval != null) {
scheduleNextOccurrence(reminder)
}
return Result.success()
}
}
Future Usage: Sync
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return Result.success()
}
}
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 15, repeatIntervalTimeUnit = TimeUnit.MINUTES,
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
Rules:
- WorkManager for all background tasks (not AlarmManager, not raw threads)
- Hilt integration via
@HiltWorker / @AssistedInject
- Network constraints on sync workers (but NOT on local reminders)
- Retry policy for failed sync attempts
- Unique work to prevent duplicate workers
Export/Import (Offline Backup)
Export Pattern
data class ExportBundle(
val version: Int = 1,
val exportedAt: Long = System.currentTimeMillis(),
val cats: List<Cat>,
val weightEntries: List<WeightEntry>,
val vaccinationEntries: List<VaccinationEntry>,
val dewormingEntries: List<DewormingEntry>,
val reminders: List<Reminder>,
)
Rules:
- Export includes all active records (deletedAt IS NULL)
- Import validates all data before writing to Room
- Import uses a transaction to ensure atomicity
- Version field for future schema evolution
- No network required — pure file I/O
DataStore Preferences
val NOTIFICATION_ENABLED = booleanPreferencesKey("notification_enabled")
val WEIGHT_UNIT = stringPreferencesKey("weight_unit")
val THEME_MODE = stringPreferencesKey("theme_mode")
Rules:
- DataStore for app preferences and settings
- Room for domain data (cats, health records, reminders)
- Never store domain data in DataStore
- Never require network to read preferences
Repository Layer Discipline
The Repository layer is the only gateway between ViewModels and the data layer. This is critical for offline-first because:
- Abstraction: Future Firebase sync logic lives in Repository, not ViewModel
- Testability: Mock the Repository interface to test ViewModels offline
- Consistency: Soft delete, syncStatus, and timestamp management happen in one place
@HiltViewModel
class CatDetailViewModel @Inject constructor(
private val catRepository: CatRepository,
private val weightEntryRepository: WeightEntryRepository,
) : ViewModel()
@HiltViewModel
class CatDetailViewModel @Inject constructor(
private val catDao: CatDao,
private val weightEntryDao: WeightEntryDao,
) : ViewModel()
If a Repository method you need doesn't exist, add it to the Repository — don't inject a DAO as a shortcut.
Offline-First Checklist
Before merging any feature, verify: