Building Offline-Capable Android Apps with Kotlin and Jetpack Compose
In today's mobile-first world, users expect apps to work seamlessly, even when there's no internet connection. This blog post will guide you through the process of building an offline-capable Android app using Kotlin and Jetpack Compose. We'll use a ToDo app as our example to illustrate key concepts and best practices.
Architecture Overview
Before diving into the code, let's outline the architecture we'll use:
-
UI Layer: Jetpack Compose for the user interface
-
ViewModel: To manage UI-related data and business logic
-
Repository: To abstract data sources and manage data flow
-
Local Database: Room for local data persistence
-
Remote Data Source: Retrofit for API calls (when online)
-
WorkManager: For background synchronization
Setting Up the Kotlin Project
First, ensure you have the necessary dependencies in your build.gradle file:
dependencies {
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.activity:activity-compose:1.7.2")
implementation("androidx.compose.ui:ui:1.4.3")
implementation("androidx.compose.ui:ui-tooling-preview:1.4.3")
implementation("androidx.compose.material3:material3:1.1.1")
// Room
implementation("androidx.room:room-runtime:2.5.2")
implementation("androidx.room:room-ktx:2.5.2")
kapt("androidx.room:room-compiler:2.5.2")
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// WorkManager
implementation("androidx.work:work-runtime-ktx:2.8.1")
}
Implementing the Local Database
We'll use Room to store ToDo items locally. First, define the entity:
@Entity(tableName = "todos")
data class ToDo(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String,
val isCompleted: Boolean = false,
val lastModified: Long = System.currentTimeMillis()
)
Next, create the DAO (Data Access Object):
@Dao
interface ToDoDao {
@Query("SELECT * FROM todos")
fun getAllToDos(): Flow<List<ToDo>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertToDo(todo: ToDo)
@Update
suspend fun updateToDo(todo: ToDo)
@Delete
suspend fun deleteToDo(todo: ToDo)
}
Finally, set up the Room database:
@Database(entities = [ToDo::class], version = 1)
abstract class ToDoDatabase : RoomDatabase() {
abstract fun todoDao(): ToDoDao
companion object {
@Volatile
private var INSTANCE: ToDoDatabase? = null
fun getDatabase(context: Context): ToDoDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
ToDoDatabase::class.java,
"todo_database"
).build()
INSTANCE = instance
instance
}
}
}
}
Implementing the Repository
The repository will manage data operations and decide whether to fetch from the local database or the remote API:
class ToDoRepository(
private val todoDao: ToDoDao,
private val apiService: ApiService
) {
val allToDos: Flow<List<ToDo>> = todoDao.getAllToDos()
suspend fun refreshToDos() {
try {
val remoteToDos = apiService.getToDos()
todoDao.insertAll(remoteToDos)
} catch (e: Exception) {
// Handle network errors
}
}
suspend fun addToDo(todo: ToDo) {
todoDao.insertToDo(todo)
try {
apiService.addToDo(todo)
} catch (e: Exception) {
// Handle network errors, maybe queue for later sync
}
}
// Implement other CRUD operations similarly
}
Setting Up the ViewModel
The ViewModel will handle the UI logic and interact with the repository:
class ToDoViewModel(private val repository: ToDoRepository) : ViewModel() {
val todos = repository.allToDos.asLiveData()
fun addToDo(title: String, description: String) {
viewModelScope.launch {
val todo = ToDo(title = title, description = description)
repository.addToDo(todo)
}
}
fun refreshToDos() {
viewModelScope.launch {
repository.refreshToDos()
}
}
// Implement other operations
}
Creating the UI with Jetpack Compose
Now, let's create the UI for our ToDo app:
@Composable
fun ToDoScreen(viewModel: ToDoViewModel) {
val todos by viewModel.todos.collectAsState(initial = emptyList())
LazyColumn {
items(todos) { todo ->
ToDoItem(todo)
}
item {
AddToDoButton(viewModel)
}
}
}
@Composable
fun ToDoItem(todo: ToDo) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.isCompleted,
onCheckedChange = { /* Update todo */ }
)
Column(modifier = Modifier.weight(1f)) {
Text(text = todo.title, fontWeight = FontWeight.Bold)
Text(text = todo.description)
}
}
}
}
@Composable
fun AddToDoButton(viewModel: ToDoViewModel) {
var showDialog by remember { mutableStateOf(false) }
Button(onClick = { showDialog = true }) {
Text("Add ToDo")
}
if (showDialog) {
AddToDoDialog(
onDismiss = { showDialog = false },
onConfirm = { title, description ->
viewModel.addToDo(title, description)
showDialog = false
}
)
}
}
@Composable
fun AddToDoDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
// Implement dialog UI here
}
Implementing Background Sync with WorkManager
To ensure our app stays up-to-date even when it's not actively running, we can use WorkManager for background synchronization:
class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
private val repository = ToDoRepository(
ToDoDatabase.getDatabase(context).todoDao(),
ApiService.create()
)
override suspend fun doWork(): Result {
return try {
repository.refreshToDos()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
Schedule the work in your application class or main activity:
class ToDoApplication : Application() {
override fun onCreate() {
super.onCreate()
setupPeriodicSync()
}
private fun setupPeriodicSync() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"ToDo_Sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}
}
Handling Conflicts
When working offline, conflicts may arise when syncing data. Implement a conflict resolution strategy:
suspend fun syncToDo(todo: ToDo) {
try {
val remoteToDo = apiService.getToDo(todo.id)
if (remoteToDo.lastModified > todo.lastModified) {
// Remote version is newer, update local
todoDao.insertToDo(remoteToDo)
} else {
// Local version is newer, update remote
apiService.updateToDo(todo)
}
} catch (e: Exception) {
// Handle network errors
}
}
Testing Offline Functionality
To ensure your app works offline:
-
Implement a network utility class to check connectivity.
-
Use this utility in your repository to decide whether to fetch from local or remote.
-
Write unit tests for your repository and ViewModel.
-
Perform UI tests with network on and off to verify behavior.
