DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Offline-First Architecture in Android — Room + Retrofit Sync

Offline-first architecture keeps users productive even without internet.

Room as Single Source of Truth

@Entity
data class Task(
    @PrimaryKey val id: String,
    val title: String,
    val completed: Boolean = false,
    val pendingSync: Boolean = true, // Track unsync'd changes
    val lastModified: Long = System.currentTimeMillis()
)

@Dao
interface TaskDao {
    @Query("SELECT * FROM Task WHERE pendingSync = 1")
    fun getPendingSyncTasks(): Flow<List<Task>>

    @Upsert
    suspend fun upsertTask(task: Task)
}
Enter fullscreen mode Exit fullscreen mode

Background Sync with WorkManager

class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        return try {
            val pendingTasks = taskDao.getPendingSyncTasks().first()
            for (task in pendingTasks) {
                api.updateTask(task)
                taskDao.update(task.copy(pendingSync = false))
            }
            Result.success()
        } catch (e: Exception) {
            Result.retry() // Retry with exponential backoff
        }
    }
}

// Schedule sync on network available
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "sync_tasks",
    ExistingPeriodicWorkPolicy.KEEP,
    PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
        .addTag("sync")
        .build()
)
Enter fullscreen mode Exit fullscreen mode

Repository Pattern

class TaskRepository(private val dao: TaskDao, private val api: TaskApi) {
    fun getTasks(): Flow<List<Task>> = dao.getTasks()

    suspend fun addTask(title: String) {
        val task = Task(id = UUID.randomUUID().toString(), title = title)
        dao.insert(task) // Insert immediately for offline
        try {
            api.createTask(task) // Sync when online
            dao.update(task.copy(pendingSync = false))
        } catch (e: Exception) {
            // Remain in pendingSync=true for retry
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Compose UI with PullToRefresh

@Composable
fun TaskListScreen(viewModel: TaskViewModel) {
    val tasks by viewModel.tasks.collectAsState()
    var isRefreshing by remember { mutableStateOf(false) }

    PullRefreshIndicator(
        refreshing = isRefreshing,
        onRefresh = {
            isRefreshing = true
            viewModel.syncNow { isRefreshing = false }
        }
    ) {
        LazyColumn {
            items(tasks) { task ->
                TaskItem(task)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Upsert for Safe API Updates

// If local version is newer, don't overwrite
suspend fun syncFromServer(remoteTasks: List<Task>) {
    remoteTasks.forEach { remote ->
        val local = dao.getTask(remote.id)
        if (local?.lastModified ?: 0 < remote.lastModified) {
            dao.upsert(remote.copy(pendingSync = false))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern ensures zero data loss and seamless experience across network transitions.


8 production-ready Android app templates on Gumroad.
Browse templatesGumroad

Top comments (0)