Techbase Academy
Android · Lesson 64

Build an Offline-First App

13 phasesRegisterSync: An Offline-First Attendance AppAndroid Studio
Phase 1 of 13
Lesson Introduction

Phones lose signal in lifts, on rural roads, in school compounds with unreliable data, and during network outages. An app that shows a spinner or an error screen whenever the network drops feels broken, even when the user's own data is sitting safely on the device. An offline-first app works the other way round: it treats the local database as the source of truth, shows what it has immediately, and synchronises with the server when it can.

This lesson is the practical capstone of the data-layer lessons in the Modern Android App Architecture programme (https://developer.android.com/courses/android-architecture). It follows the official guide Build an offline-first app (https://developer.android.com/topic/architecture/data-layer/offline-first) and uses Room, Retrofit and WorkManager (https://developer.android.com/topic/libraries/architecture/workmanager).

By the end you will be able to:

  • Explain offline-first and why the database is the source of truth.
  • Choose read and write strategies, including queued writes.
  • Design sync with WorkManager: constraints, retries, back-off and unique work.
  • Pick a conflict resolution policy and understand its trade-offs.
  • Monitor connectivity with a Flow.
  • Show sync status honestly in the UI.
  • Test the sync logic.

Big idea: the UI never waits for the network. It reads the database, and the network is just one of the things that updates the database.

Honesty note: version numbers are placeholders ("latest stable"). Room 3 imports are used, as in lesson 34; change them to androidx.room for Room 2.x. The server in this lesson is imaginary; the sync logic is real and testable with fakes.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 13
Prerequisite Concepts
  • Room, DAOs and Flow (lesson 34).
  • Repositories, data sources and error handling (lesson 62).
  • UI state production (lesson 61).
  • Retrofit (lesson 29).
  • Coroutines and Flow (lesson 33).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 13
Conceptual Understanding: Offline-First

Not just a cache

A cache speeds things up but the network is still in charge: if there is no cache entry, nothing shows. In an offline-first app, the database is complete enough to use on its own, and the network exists to keep it fresh and to send local changes upstream.

text
        ┌───────────────┐
        │      UI       │  reads ONLY from the repository
        └───────┬───────┘
                │ Flow
        ┌───────▼───────┐
        │  Repository   │
        └───┬───────┬───┘
   reads    │       │   writes (and a sync request)
        ┌───▼───┐ ┌─▼────────────┐
        │  DB   │ │ Sync worker  │──► Network
        │ (SSOT)│◄┤ (WorkManager)│◄── Network
        └───────┘ └──────────────┘

Why it is worth the effort

Benefit Explanation
Works without a connection Users can read and change data offline
Instant UI Screens render from disk, not after a round trip
Resilient Flaky networks are normal, not an error state
Saves data and battery Fewer, batched requests
Simple UI code The UI has a single flow to observe

The cost is sync logic: you must reason about what happens when the server and the device disagree.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: Offline-First. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 13
Step-by-Step Build 1: The Running Example, "RegisterSync"

A teacher records attendance for each class. Requirements:

  • It must work in a classroom with no network.
  • Records are uploaded when a connection is available.
  • The teacher can see which records are not yet uploaded.
  • The server may also contain records entered by other teachers; they must appear.

Models and states

Each local record has a sync state:

kotlin
enum class SyncState { SYNCED, PENDING_UPLOAD, FAILED }
@Entity(tableName = "attendance")
data class AttendanceEntity(
 @PrimaryKey val id: String, // client-generated UUID, so records can be created offline
 val studentName: String,
 val date: String, // ISO-8601 date
 val present: Boolean,
 val updatedAtMillis: Long, // used for conflict resolution
 val syncState: SyncState
)

Key design choices:

  • Client-generated ids (UUIDs): an offline device cannot ask the server for an id. A UUID lets the client create records safely, and the server accepts them.
  • updatedAtMillis: when the record was last edited, used to decide who wins in conflicts.
  • syncState: a row in PENDING_UPLOAD is a change the server has not seen. The enum needs a type converter in Room (store the name as a string). In Room 3 this is a @ColumnTypeConverter; in Room 2.x it is @TypeConverter (see lesson 34).
kotlin
class SyncStateConverters {
 @ColumnTypeConverter
 fun toString(value: SyncState): String = value.name
 @ColumnTypeConverter
 fun fromString(value: String): SyncState = SyncState.valueOf(value)
}

(Register it with @TypeConverters(SyncStateConverters::class) on the database class. Check the Room 3 documentation for the exact annotation names.)

The DAO

kotlin
@Dao
interface AttendanceDao {
 @Query("SELECT * FROM attendance WHERE date = :date ORDER BY studentName")
 fun observeForDate(date: String): Flow<List<AttendanceEntity>>
 @Query("SELECT * FROM attendance WHERE syncState != 'SYNCED'")
 suspend fun pendingChanges(): List<AttendanceEntity>
 @Query("SELECT COUNT(*) FROM attendance WHERE syncState = 'PENDING_UPLOAD'")
 fun observePendingCount(): Flow<Int>
 @Upsert
 suspend fun upsert(item: AttendanceEntity)
 @Upsert
 suspend fun upsertAll(items: List<AttendanceEntity>)
 @Query("SELECT * FROM attendance WHERE id = :id")
 suspend fun find(id: String): AttendanceEntity?
 @Query("UPDATE attendance SET syncState = 'SYNCED' WHERE id IN (:ids)")
 suspend fun markSynced(ids: List<String>)
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: The Running Example, "RegisterSync". Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 13
Step-by-Step Build 2: Read Strategy

The UI reads only from the database:

kotlin
interface AttendanceRepository {
 fun observeForDate(date: String): Flow<List<AttendanceRecord>>
 fun observePendingCount(): Flow<Int>
 suspend fun mark(studentName: String, date: String, present: Boolean)
 suspend fun syncNow(): Result<Unit>
}
kotlin
class OfflineFirstAttendanceRepository(
 private val dao: AttendanceDao,
 private val api: AttendanceApi,
 private val syncScheduler: SyncScheduler,
 private val clock: () -> Long = System::currentTimeMillis,
 private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : AttendanceRepository {
 override fun observeForDate(date: String): Flow<List<AttendanceRecord>> =
 dao.observeForDate(date)
 .map { list -> list.map { it.toModel() } }
 .flowOn(ioDispatcher)
 override fun observePendingCount(): Flow<Int> = dao.observePendingCount()
 // mark() and syncNow() follow
}

No network call appears in the read path. Screens appear instantly, from disk, whether or not the device is online.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Read Strategy. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 13
Step-by-Step Build 3: Write Strategy (Queued Writes)

When the teacher taps "present":

  1. Write to the database immediately, marked PENDING_UPLOAD.
  2. The UI updates at once because it observes the database.
  3. Schedule a sync that uploads pending changes when a connection exists.
kotlin
override suspend fun mark(studentName: String, date: String, present: Boolean) {
 withContext(ioDispatcher) {
 val id = "$date|$studentName" // stable id for one student on one date
 dao.upsert(
 AttendanceEntity(
 id = id,
 studentName = studentName,
 date = date,
 present = present,
 updatedAtMillis = clock(),
 syncState = SyncState.PENDING_UPLOAD
 )
 )
 }
 syncScheduler.requestSync()
}

The method returns as soon as the local write finishes, so there is no spinner, and the user cannot lose their work by walking out of coverage. The word queued means the database row itself is the queue: pending rows are the work to do.

A stable id ("$date|$studentName") means marking the same student twice on the same day updates one row rather than creating a duplicate. Choose ids that make your operations idempotent (safe to repeat). Idempotence is essential, because retries mean the server may see the same upload more than once.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Write Strategy (Queued Writes). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 13
Step-by-Step Build 4: Sync with WorkManager

Why WorkManager

WorkManager runs deferrable, guaranteed background work: it survives app restarts and device reboots, respects constraints (such as network availability) and retries with back-off. It is the recommended tool for sync. Do not use a bare coroutine, a Service you manage by hand, or GlobalScope: they die with the process.

Add the dependency (use the latest stable version; recent releases include the Kotlin extensions in the main artifact, older ones need work-runtime-ktx):

libs.versions.toml
[versions]
work = "<latest stable>"
[libraries]
androidx-work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "work" }

The worker

kotlin
class SyncWorker(
 appContext: Context,
 params: WorkerParameters,
 private val repository: AttendanceRepository
) : CoroutineWorker(appContext, params) {
 override suspend fun doWork(): Result {
 val outcome = repository.syncNow()
 return when {
 outcome.isSuccess -> Result.success()
 runAttemptCount < 5 -> Result.retry() // WorkManager applies back-off
 else -> Result.failure()
 }
 }
}

CoroutineWorker gives you a suspend doWork. Returning Result.retry() asks WorkManager to run it again later with exponential back-off. (Inside doWork the name Result means ListenableWorker.Result; the repository's Result<Unit> is Kotlin's. Import carefully or alias one of them.)

A word on constructor injection: WorkManager creates workers itself, so passing repository through the constructor needs a WorkerFactory (or Hilt's @HiltWorker, lesson 65). A simple manual factory:

kotlin
class AppWorkerFactory(private val repository: AttendanceRepository) : WorkerFactory() {
 override fun createWorker(
 appContext: Context,
 workerClassName: String,
 workerParameters: WorkerParameters
 ): ListenableWorker? = when (workerClassName) {
 SyncWorker::class.java.name -> SyncWorker(appContext, workerParameters, repository)
 else -> null
 }
}

Register it by implementing Configuration.Provider in your Application class and returning a Configuration that uses the factory (the official WorkManager documentation shows the current form).

The scheduler

kotlin
interface SyncScheduler { fun requestSync() }
class WorkManagerSyncScheduler(private val context: Context) : SyncScheduler {
 override fun requestSync() {
 val request = OneTimeWorkRequestBuilder<SyncWorker>()
 .setConstraints(
 Constraints.Builder()
 .setRequiredNetworkType(NetworkType.CONNECTED)
 .build()
 )
 .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
 .build()
 WorkManager.getInstance(context).enqueueUniqueWork(
 "attendance-sync",
 ExistingWorkPolicy.KEEP, // if a sync is already queued, do not queue another
 request
 )
 }
}
  • Constraint: only run when the network is connected. WorkManager waits for it, even across reboots.
  • Unique work with KEEP: twenty quick taps cause one upload, because the sync reads all pending rows.
  • Back-off: if the server is down, retries become gradually less frequent.

You can also schedule a periodic pull so that the device receives other teachers' changes:

kotlin
val periodic = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
 .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build())
 .build()
WorkManager.getInstance(context)
 .enqueueUniquePeriodicWork("attendance-periodic", ExistingPeriodicWorkPolicy.KEEP, periodic)

(Periodic work has a minimum interval of 15 minutes. UNMETERED means Wi-Fi, which spares mobile data; choose what suits your users.)

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Sync with WorkManager. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 13
Step-by-Step Build 5: The Sync Algorithm

A robust sync is push then pull: upload local changes first, then download the server's.

kotlin
override suspend fun syncNow(): Result<Unit> = withContext(ioDispatcher) {
 runCatchingNetwork {
 // 1. PUSH: send everything pending
 val pending = dao.pendingChanges()
 if (pending.isNotEmpty()) {
 api.upload(pending.map { it.toDto() })
 dao.markSynced(pending.map { it.id })
 }
 // 2. PULL: fetch changes from the server and merge
 val remote = api.changesSince(lastSyncToken())
 mergeRemote(remote)
 saveSyncToken(remote.nextToken)
 }
}

Note: if the upload throws, nothing is marked as synced, so the next run re-uploads the same rows. That is why the server operation must be idempotent (upsert by id).

A careful detail: rows edited while the upload is in flight. If the user changes a row after pendingChanges() reads it but before markSynced runs, marking it synced would hide a newer change. Fix it by marking synced only if the row has not changed since:

kotlin
@Query("UPDATE attendance SET syncState = 'SYNCED' WHERE id = :id AND updatedAtMillis = :version")
suspend fun markSyncedIfUnchanged(id: String, version: Long)

and calling it for each uploaded item with its updatedAtMillis. Otherwise the newer edit would never upload.

Conflict resolution

A conflict happens when the same record was changed on the device and on the server. You must pick a policy.

Policy Rule Good for Risk
Server wins Remote overwrites local Reference data (class lists) Lose offline edits
Client wins Local overwrites remote Single-user personal data Lose other users' edits
Last write wins Compare timestamps Simple collaboration Clock differences; silent loss
Field-level merge Merge different fields Records with independent fields More code
Ask the user Show both versions Critical data More UI

A simple last-write-wins merge, protecting local pending edits:

kotlin
private suspend fun mergeRemote(remote: List<AttendanceDto>) {
 for (dto in remote) {
 val local = dao.find(dto.id)
 val keepLocal = local != null &&
 local.syncState == SyncState.PENDING_UPLOAD &&
 local.updatedAtMillis > dto.updatedAtMillis
 if (!keepLocal) dao.upsert(dto.toEntity(SyncState.SYNCED))
 }
}

Be honest about the limit: device clocks can be wrong, so timestamps are a heuristic. For data where silent loss matters, use server-assigned versions (an incrementing version or ETag) and have the server reject stale writes, then let the client re-fetch and retry or ask the user.

Deletes

You cannot simply delete a row if you need to tell the server. Use a tombstone: mark the row deleted = true, sync it, then really remove it after the server confirms.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: The Sync Algorithm. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 13
Step-by-Step Build 6: Connectivity and Honest UI

Observing connectivity

Connectivity is a hint, not a guarantee (a device can be "connected" to Wi-Fi with no internet). Still, it is useful for showing a banner. Observe it as a Flow:

kotlin
class ConnectivityObserver(context: Context) {
 private val manager = context.getSystemService(ConnectivityManager::class.java)
 val isOnline: Flow<Boolean> = callbackFlow {
 val callback = object : ConnectivityManager.NetworkCallback() {
 override fun onAvailable(network: Network) { trySend(true) }
 override fun onLost(network: Network) { trySend(false) }
 }
 manager.registerDefaultNetworkCallback(callback)
 trySend(manager.activeNetwork != null)
 awaitClose { manager.unregisterNetworkCallback(callback) }
 }.distinctUntilChanged()
}

(This needs the ACCESS_NETWORK_STATE permission in the manifest.)

Telling the user the truth

kotlin
data class AttendanceUiState(
 val records: List<AttendanceRecord> = emptyList(),
 val pendingCount: Int = 0,
 val online: Boolean = true
)
class AttendanceViewModel(
 repository: AttendanceRepository,
 connectivity: ConnectivityObserver,
 private val date: String
) : ViewModel() {
 val uiState: StateFlow<AttendanceUiState> = combine(
 repository.observeForDate(date),
 repository.observePendingCount(),
 connectivity.isOnline
 ) { records, pending, online -> AttendanceUiState(records, pending, online) }
 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AttendanceUiState())
}
kotlin
@Composable
fun SyncBanner(pending: Int, online: Boolean) {
 val text = when {
 pending > 0 && !online -> "$pending changes saved on this phone. They will upload when you are online."
 pending > 0 -> "Uploading $pending changes…"
 !online -> "Offline. Showing saved data."
 else -> return
 }
 Surface(color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.fillMaxWidth()) {
 Text(text, Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
 }
}

The words matter. "Saved on this phone" reassures the teacher that nothing is lost; "Offline" without more would worry them.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: Connectivity and Honest UI. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 13
Step-by-Step Build 7: Testing Sync

Because the sync logic is in the repository and the database and API are replaceable, you can test it with fakes:

kotlin
class SyncTest {
 private val dao = FakeAttendanceDao()
 private val api = FakeAttendanceApi()
 private val scheduler = object : SyncScheduler { var calls = 0; override fun requestSync() { calls++ } }
 @Test
 fun mark_saves_locally_as_pending_and_requests_sync() = runTest {
 val repo = OfflineFirstAttendanceRepository(dao, api, scheduler, { 1_000 }, StandardTestDispatcher(testScheduler))
 repo.mark("Ada", "2026-10-26", present = true)
 val row = dao.find("2026-10-26|Ada")!!
 assertEquals(SyncState.PENDING_UPLOAD, row.syncState)
 assertEquals(1, scheduler.calls)
 }
 @Test
 fun failed_upload_keeps_rows_pending() = runTest {
 api.failUploads = true
 val repo = OfflineFirstAttendanceRepository(dao, api, scheduler, { 1_000 }, StandardTestDispatcher(testScheduler))
 repo.mark("Ada", "2026-10-26", true)
 val result = repo.syncNow()
 assertTrue(result.isFailure)
 assertEquals(SyncState.PENDING_UPLOAD, dao.find("2026-10-26|Ada")!!.syncState)
 }
 @Test
 fun newer_local_edit_survives_older_remote_copy() = runTest {
 dao.upsert(AttendanceEntity("d|Ada", "Ada", "d", true, updatedAtMillis = 2_000, syncState = SyncState.PENDING_UPLOAD))
 api.remote = listOf(AttendanceDto("d|Ada", "Ada", "d", present = false, updatedAtMillis = 1_000))
 val repo = OfflineFirstAttendanceRepository(dao, api, scheduler, { 3_000 }, StandardTestDispatcher(testScheduler))
 repo.syncNow()
 assertTrue(dao.find("d|Ada")!!.present)
 }
}

To test the worker itself, the androidx.work:work-testing artifact provides TestListenableWorkerBuilder, which runs doWork directly and lets you assert on Result.success(), Result.retry() and Result.failure(). On a device, adb shell cmd connectivity airplane-mode enable (on supported versions) or the emulator's network controls let you try the full flow: mark attendance offline, watch the banner, turn the network on, and watch the pending count drop to zero.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 7: Testing Sync. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 13
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Why client-generated ids?

A teammate suggests letting the server assign ids and waiting for the response before showing a new record. Give two problems with that in an offline-first app.

(1) With no connection there is no response, so the record cannot be created at all, which defeats offline-first. (2) Waiting for the network blocks the UI and makes the record appear late. Client-generated ids (UUIDs or deterministic keys) let the device create records immediately, and the server accepts them. Deterministic keys also make retries idempotent.

🎯 Your Challenge

Exercise 2: Pick a policy

For each, choose a conflict policy: (a) a class list maintained by the school office; (b) a teacher's personal notes on their own device and account; (c) a shared marks sheet edited by several teachers.

(a) Server wins, because the office owns the data and devices should not overwrite it. (b) Client wins (or last write wins) since only one person edits it. (c) Field-level merge or server-assigned versions with "ask the user", because silent loss of a teacher's marks would be serious.

🎯 Your Challenge

Exercise 3: Find the race

syncNow reads pending rows, uploads them, then marks all those rows as synced. Describe a bug when the user edits a row during the upload and give the fix.

The edited row is overwritten as SYNCED after the upload, though the server only has the old version, so the new edit is never uploaded. Fix: mark a row synced only if its version (updatedAtMillis or a counter) is unchanged since it was read, using UPDATE ... WHERE id = :id AND updatedAtMillis = :version. Rows that changed stay PENDING_UPLOAD and go out in the next sync.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 13
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: Reading from the network in the UI path. Fix: the UI reads only from the database.

Mistake #2: Waiting for the server before showing a write. Fix: write locally first, then sync.

Mistake #3: Server-assigned ids for offline-created records. Fix: use UUIDs or deterministic keys.

Mistake #4: Non-idempotent uploads. Fix: upsert by id on the server.

Mistake #5: Using a coroutine or service instead of WorkManager. Fix: use WorkManager with constraints.

Mistake #6: Deleting local data when a refresh fails. Fix: change data only after success.

Mistake #7: Marking rows synced after concurrent edits. Fix: compare versions.

Mistake #8: Trusting device clocks completely. Fix: prefer server versions where loss matters.

Mistake #9: Hiding sync state from users. Fix: show pending counts and clear wording.

Mistake #10: Queuing a new job for every tap. Fix: unique work with KEEP.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 13
Knowledge Check

Question 1. What is the difference between a cache and an offline-first design?

A cache accelerates a network-driven app; in offline-first, the database is the complete source of truth the UI uses, and the network only syncs it.

Question 2. Why does enqueueUniqueWork with KEEP help?

It prevents duplicate queued syncs; one run uploads all pending rows.

Question 3. What is idempotence and why does sync need it?

An operation that gives the same result when repeated. Retries and re-uploads are normal, so the server must handle the same record twice safely.

Question 4. Name two conflict policies and a risk of each.

Server wins (loses offline edits) and last write wins (clock errors can lose newer data); others include client wins and field-level merge.

Question 5. Which WorkManager feature delays work until the network is available?

Constraints, specifically setRequiredNetworkType(NetworkType.CONNECTED).

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
RegisterSync: An Offline-First Attendance App

Project goal: Build the attendance app from this lesson end to end, and prove it works with the network off.

Requirements:

  1. Room database with AttendanceEntity, SyncState and the DAO (use Room 3 or 2.x consistently).
  2. An AttendanceApi (Retrofit, or a fake in-memory server with delay) with upload and changesSince.
  3. OfflineFirstAttendanceRepository with observeForDate, observePendingCount, mark and syncNow.
  4. A SyncWorker with constraints, back-off and unique work, plus a worker factory.
  5. A last-write-wins merge that protects newer local edits, and version-checked markSynced.
  6. A ConnectivityObserver and a SyncBanner showing honest messages.
  7. A screen listing students for today with present/absent toggles.
  8. At least four tests: local write is pending, failed upload keeps pending, merge protects newer local edits, and concurrent edit during upload stays pending.
  9. A manual test script (written down) for airplane mode: mark five students offline, restart the app, go online, confirm the count reaches zero.

Stretch goal: implement tombstone deletes and show a "Retry" button for rows in FAILED state.

Version-checked marking, used in syncNow:

kotlin
val pending = dao.pendingChanges()
if (pending.isNotEmpty()) {
 api.upload(pending.map { it.toDto() })
 pending.forEach { dao.markSyncedIfUnchanged(it.id, it.updatedAtMillis) }
}

A concurrency test:

kotlin
@Test
fun edit_during_upload_stays_pending() = runTest {
 val repo = OfflineFirstAttendanceRepository(dao, api, scheduler, { 1_000 }, StandardTestDispatcher(testScheduler))
 repo.mark("Ada", "d", true)
 api.onUpload = {
 // simulate a new edit arriving while the upload is running
 dao.upsert(dao.find("d|Ada")!!.copy(present = false, updatedAtMillis = 2_000, syncState = SyncState.PENDING_UPLOAD))
 }
 repo.syncNow()
 val row = dao.find("d|Ada")!!
 assertEquals(SyncState.PENDING_UPLOAD, row.syncState)
 assertFalse(row.present)
}

The manual airplane-mode script (example):

text
1. Install, open, switch the phone to airplane mode.
2. Mark 5 students present. Banner: "5 changes saved on this phone..."
3. Force-stop and reopen the app. The 5 marks are still there.
4. Switch airplane mode off. Banner: "Uploading 5 changes..."
5. After a few seconds the banner disappears; pending count = 0.
6. Check the (fake or real) server: 5 records present.
1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 64 project”.

3

On github.com click New repository, name it android-lesson-64-build-an-offline-first-app, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 64 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: Offline-First
  • Step-by-Step Build 1: The Running Example, "RegisterSync"
  • Step-by-Step Build 2: Read Strategy
  • Step-by-Step Build 3: Write Strategy (Queued Writes)
  • Step-by-Step Build 4: Sync with WorkManager
  • Step-by-Step Build 5: The Sync Algorithm
  • Step-by-Step Build 6: Connectivity and Honest UI
  • Step-by-Step Build 7: Testing Sync
  • Guided Practice Exercises
  • Common Beginner Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close