top of page
Writer's pictureRobin Alex Panicker

Building Offline-Capable Android Apps with Kotlin and Jetpack Compose


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:

  1. Implement a network utility class to check connectivity.

  2. Use this utility in your repository to decide whether to fetch from local or remote.

  3. Write unit tests for your repository and ViewModel.

  4. Perform UI tests with network on and off to verify behavior.


Conclusion

Building an offline-capable Android app requires careful consideration of data flow, synchronization, and conflict resolution. By using Room for local storage, Retrofit for API calls, and WorkManager for background sync, you can create a robust offline experience for your users.


Remember to handle edge cases, such as first-time app usage without internet, and always provide clear feedback to users about the sync status of their data.


Would you like me to explain or break down any part of this code?

Blog for Mobile App Developers, Testers and App Owners

 

This blog is from Finotes Team. Finotes is a lightweight mobile APM and bug detection tool for iOS and Android apps.

In this blog we talk about iOS and Android app development technologies, languages and frameworks like Java, Kotlin, Swift, Objective-C, Dart and Flutter that are used to build mobile apps. Read articles from Finotes team about good programming and software engineering practices, testing and QA practices, performance issues and bugs, concepts and techniques. 

Monitor & Improve Performance of your Mobile App

 

Detect memory leaks, abnormal memory usages, crashes, API / Network call issues, frame rate issues, ANR, App Hangs, Exceptions and Errors, and much more.

Explore Finotes

bottom of page