Android with Kotlin and Jetpack Compose · Architecture and Data: ViewModels, Navigation, Networking and Offline
Room and Offline-First Data
Store data on the device with Room, observe it as a Flow, and build an offline-first repository: the UI reads the local database, and sync keeps it up to date with the server.
What you'll build
A notes app that opens instantly with the notes saved on the device, lets you add and edit notes with no connection, and syncs changes with the server when it's back online.
The idea
Offline-first means the local database is the single source of truth for the UI:
- The screen observes the database (a
Flowfrom Room), so it always shows saved data, even offline. - A refresh fetches from the network and writes into the database; the Flow then emits the new data automatically.
- Local edits are saved immediately and marked as pending; a sync job sends them later and resolves conflicts (for example, last write wins by
updatedAt).
Room is the SQLite library from Jetpack: you declare entities (tables), DAOs (queries, checked at compile time) and a database class. DAO functions can return Flow to observe changes, or be suspend for one-off reads and writes.
The code
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val text: String,
val updatedAt: Long,
val pendingSync: Boolean = false,
)
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<NoteEntity>>
@Query("SELECT * FROM notes WHERE pendingSync = 1")
suspend fun pending(): List<NoteEntity>
@Upsert
suspend fun upsert(notes: List<NoteEntity>)
}
@Database(entities = [NoteEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
}
class NoteRepository(private val dao: NoteDao, private val api: NotesApi) {
val notes: Flow<List<Note>> = dao.observeAll().map { rows -> rows.map { it.toDomain() } }
suspend fun save(id: String, text: String, now: Long) =
dao.upsert(listOf(NoteEntity(id, text, now, pendingSync = true)))
suspend fun sync() {
val pending = dao.pending()
api.push(pending.map { it.toDto() }) // send local edits first
val remote = api.changedSince(lastSyncTime())
dao.upsert(merge(remote, pending)) // then apply server changes
}
}
Add Room with KSP: ksp("androidx.room:room-compiler:2.8.5") and implementation("androidx.room:room-ktx:2.8.5"). Increase version and add a migration whenever the schema changes, or users lose data.
Try the logic
The repository's merge rule (last write wins, and unsynced local edits are never lost) as runnable code:
data class Note(val id: String, val text: String, val updatedAt: Long, val pendingSync: Boolean = false)
fun merge(local: List<Note>, remote: List<Note>): List<Note> {
val byId = local.associateBy { it.id }.toMutableMap()
for (r in remote) {
val l = byId[r.id]
byId[r.id] = when {
l == null -> r // new on the server
l.pendingSync && l.updatedAt > r.updatedAt -> l // our newer edit wins, still to be pushed
else -> r.copy(pendingSync = false) // server is newer
}
}
return byId.values.sortedByDescending { it.updatedAt }
}
fun main() {
val local = listOf(
Note("a", "Buy milk (edited offline)", updatedAt = 300, pendingSync = true),
Note("b", "Call Ana", updatedAt = 100),
)
val remote = listOf(
Note("a", "Buy milk", updatedAt = 200),
Note("b", "Call Ana at 5pm", updatedAt = 250),
Note("c", "Book flights", updatedAt = 150),
)
merge(local, remote).forEach { println("${it.id} | ${it.text} | ${it.updatedAt} | pending=${it.pendingSync}") }
}
Output
a | Buy milk (edited offline) | 300 | pending=true b | Call Ana at 5pm | 250 | pending=false c | Book flights | 150 | pending=false
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Showing network results directly. The UI flickers and is empty offline; write to the database and let the UI observe it.
- Forgetting migrations. Changing an entity without bumping the version and providing a
Migrationcrashes on upgrade (or wipes data with destructive fallback). - Queries on the main thread. Room rejects them by default; use
suspendfunctions andFlow. - Ignoring conflicts. Decide a rule (last write wins, merge fields, or ask the user) before two devices edit the same record.
Key points
- Offline-first: the local database is the source of truth; the network refreshes it.
- Room gives compile-time-checked SQL,
Flowqueries andsuspendwrites. - Mark local edits as pending and sync them later with a clear conflict rule.
- Plan schema migrations from the first release.
Further reading: Build an offline-first app, Room.
Practice questions
What does offline-first mean for an Android app's architecture?
Answer
The UI reads from a local database (the single source of truth), which is observed as a Flow. Network requests only update that database, and local changes are written locally first and synced later, so the app works and stays consistent without a connection.
What happens if you change a Room entity without a migration?
Answer
When an installed app with the old schema opens the database, Room detects the version or schema mismatch and throws, crashing the app, unless you configured destructive migration, which deletes the user's data. Bump the version and provide a Migration (or an auto-migration).
Open in the app to mark lessons complete, take notes and track your progress.