4 min read
Designing Offline-First Android Apps for African Connectivity Constraints

Mobile connectivity in Kenya is often inconsistent. Apps that assume stable connections fail users during commutes or in rural areas. An offline-first approach treats local data as the single source of truth, syncing with the network only when it becomes available. I’ve been building this way for the past year and the pattern has become my default.

1. Room as Single Source of Truth

The local database tracks the synchronization status of every record. This ensures the UI always has data to display, regardless of the network state.

@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey val id: String,
    val title: String,
    val completed: Boolean,
    val updatedAt: Long,
    val syncStatus: SyncStatus = SyncStatus.PENDING
)

enum class SyncStatus { PENDING, SYNCED }

@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY updatedAt DESC")
    fun observeTasks(): LiveData<List<Task>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun upsert(task: Task)

    @Query("SELECT * FROM tasks WHERE syncStatus = :status")
    fun getTasksByStatus(status: SyncStatus): List<Task>

    @Query("UPDATE tasks SET syncStatus = :status WHERE id IN (:ids)")
    fun markSynced(ids: List<String>, status: SyncStatus = SyncStatus.SYNCED)
}

2. Repository: Local First

The repository pattern ensures the app always serves data from Room. Remote updates happen in the background without blocking the user.

I’ve been experimenting with Kotlin coroutines for the async work. They’re still marked experimental in the current kotlinx.coroutines library, but the API is stable enough for me to use them in production with the caveat that things could change in a minor release.

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

    suspend fun upsertLocal(task: Task) = withContext(Dispatchers.IO) {
        dao.upsert(task.copy(
            updatedAt = System.currentTimeMillis(),
            syncStatus = SyncStatus.PENDING
        ))
        enqueueSync()
    }

    suspend fun refreshFromRemote() = withContext(Dispatchers.IO) {
        try {
            val remote = api.getTasks()
            remote.forEach {
                dao.upsert(it.copy(syncStatus = SyncStatus.SYNCED))
            }
        } catch (e: Exception) {
            // Gracefully fall back to cached data
        }
    }
}

3. WorkManager for Background Sync

WorkManager launched in alpha at Google I/O this May and it’s exactly what I’ve been waiting for. Previous approaches - JobScheduler, AlarmManager - were fragmented across API levels. WorkManager normalises this with a single API and lets me set constraints so the sync only runs when the device has an active connection.

class SyncWorker(
    appContext: Context,
    params: WorkerParameters,
    private val dao: TaskDao,
    private val api: TaskApi
) : Worker(appContext, params) {
    override fun doWork(): Result {
        return try {
            val pending = dao.getTasksByStatus(SyncStatus.PENDING)
            if (pending.isEmpty()) return Result.success()

            api.syncTasks(pending)
            dao.markSynced(pending.map { it.id })
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }
}

Enqueue with Network Constraints

fun enqueueSyncWork(context: Context) {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()

    val request = OneTimeWorkRequestBuilder<SyncWorker>()
        .setConstraints(constraints)
        .build()

    WorkManager.getInstance(context)
        .enqueueUniqueWork("immediate_sync", ExistingWorkPolicy.KEEP, request)
}

4. UI Implementation

Using LiveData and ViewModel, the UI updates instantly when the local database changes. Users see their additions immediately, while a “pending” icon can indicate data that hasn’t reached the server yet.

For launching the coroutine from the ViewModel, I’m using a manually managed scope with a Job that gets cancelled in onCleared. It’s a bit more ceremony than I’d like but it works reliably:

class TaskViewModel(private val repository: TaskRepository) : ViewModel() {
    private val viewModelJob = Job()
    private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)

    val tasks: LiveData<List<Task>> = repository.observeTasks()

    fun addTask(title: String) {
        uiScope.launch {
            val task = Task(
                id = UUID.randomUUID().toString(),
                title = title,
                completed = false,
                updatedAt = System.currentTimeMillis()
            )
            repository.upsertLocal(task)
        }
    }

    override fun onCleared() {
        super.onCleared()
        viewModelJob.cancel()
    }
}

This architecture keeps the app functional and responsive even when the user is completely offline. The sync status in Room means I always know what’s pending - which is useful for both the UI and debugging when things go wrong in the field.