Android + Room + Retrofit
WMC Exam Cheatsheet
01
Quick Start / Setup
β οΈ Do This First β Without It, Nothing Works
AndroidManifest.xml β Internet Permission
xml
<!-- AndroidManifest.xml β add BEFORE the <application> tag -->
<uses-permission android:name="android.permission.INTERNET"/>
libs.versions.toml
toml
[versions]
agp = "9.2.1"
kotlin = "2.4.0"
ksp = "2.3.7"
retrofit = "3.0.0"
converterGson = "3.0.0"
loggingInterceptor = "5.3.2"
roomVersion = "2.8.4"
lifecycleRuntimeKtx = "2.10.0"
kotlinxCoroutinesAndroid = "1.11.0"
[libraries]
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" }
logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
[plugins]
google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
build.gradle.kts (app module)
kotlin
plugins {
alias(libs.plugins.google.devtools.ksp) // required for Room
}
dependencies {
// Retrofit + Gson converter + OkHttp logging
implementation(libs.retrofit)
implementation(libs.converter.gson)
implementation(libs.logging.interceptor)
// Room β use KSP, NOT kapt!
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx) // Flow + Coroutines support
ksp(libs.androidx.room.compiler) // annotation processor via KSP
// ViewModel + Coroutines
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.kotlinx.coroutines.android)
}
KSP vs kapt: Room's annotation processor must be declared with
ksp(...), not kapt(...). kapt is deprecated and will fail on modern Android projects. The KSP plugin must also be listed in the plugins { } block.
02
Retrofit Client Setup
π ArticleDemo
kotlin Β· RetroFitClient.kt
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetroFitClient {
private const val BASE_URL = "https://jsonplaceholder.typicode.com/"
// β οΈ BASE_URL must end with a trailing slash /
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY // logs full request + response in Logcat
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
val articleApi: ArticleApi = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ArticleApi::class.java)
}
The
object keyword makes RetroFitClient a singleton β one shared Retrofit instance for the whole app. GsonConverterFactory automatically converts JSON β Kotlin data classes.
03
API Interface & Annotations
π ArticleDemo
kotlin Β· ArticleApi.kt
interface ArticleApi {
@GET("posts")
suspend fun getArticles(
@Query("_page") page: Int = 1, // β /posts?_page=1&_limit=20
@Query("_limit") limit: Int = 20
): List<Article>
@GET("posts/{id}")
suspend fun getArticleById(
@Path("id") id: Int // β /posts/42
): Article
@POST("posts")
suspend fun createArticle(
@Body article: Article // serialized to JSON request body
): Article
@PUT("posts/{id}")
suspend fun updateArticle(
@Path("id") id: Int,
@Body article: Article
): Article
@DELETE("posts/{id}")
suspend fun deleteArticle(
@Path("id") id: Int
)
// All functions are suspend β must be called from a coroutine scope
}
Annotation Quick Reference
| Annotation | HTTP Method / Purpose | Example |
|---|---|---|
@GET("path") | HTTP GET β fetch data | @GET("users") |
@POST("path") | HTTP POST β create resource | @POST("users") |
@PUT("path/{id}") | HTTP PUT β full replace | @PUT("users/{id}") |
@PATCH("path/{id}") | HTTP PATCH β partial update | @PATCH("users/{id}") |
@DELETE("path/{id}") | HTTP DELETE β remove resource | @DELETE("users/{id}") |
@Path("id") | Replaces {id} in URL path | @Path("id") id: Int |
@Query("key") | Appends ?key=value to URL | @Query("page") page: Int |
@Body | Serializes object as JSON request body | @Body user: User |
@Header("key") | Single dynamic request header | @Header("Authorization") t: String |
@Headers([...]) | Static headers on the function | @Headers(["Content-Type: application/json"]) |
04
Header Parameters & Authentication
Single Static Header
kotlin
@Headers("Content-Type: application/json")
@GET("users")
suspend fun getUsers(): List<User>
Multiple Static Headers
kotlin
@Headers([
"Content-Type: application/json",
"Accept: application/json"
])
@POST("users")
suspend fun createUser(@Body user: User): User
Dynamic Authorization Header (Bearer Token)
kotlin
// Interface: dynamic header passed per call
@GET("profile")
suspend fun getProfile(
@Header("Authorization") token: String
): UserProfile
// Call site β prefix with "Bearer "
val profile = api.getProfile("Bearer $accessToken")
OkHttp Interceptor β Inject Token on Every Request
Use this when every API call needs the same auth header, so you don't have to pass it manually each time.
kotlin Β· AuthInterceptor.kt
import okhttp3.Interceptor
import okhttp3.Response
class AuthInterceptor(private val token: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.addHeader("Content-Type", "application/json")
.build()
return chain.proceed(request)
}
}
kotlin Β· RetroFitClient.kt β wire up the interceptor
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(token = "your_token_here")) // auth first
.addInterceptor(loggingInterceptor) // logging last
.connectTimeout(30, TimeUnit.SECONDS)
.build()
Add the
AuthInterceptor before the logging interceptor so the Authorization header appears in Logcat output for debugging.
05
DTOs & JSON Mapping
π ArticleDemo
kotlin Β· Article.kt
// Kotlin property names match JSON keys exactly β no @SerializedName needed
data class Article(
val id: Int,
val userId: Int,
val title: String,
val body: String
)
// Same class is reused as both response DTO and @Body request payload
When JSON Keys Differ from Kotlin Names β @SerializedName
kotlin
import com.google.gson.annotations.SerializedName
data class User(
@SerializedName("user_id") val id: Int,
@SerializedName("first_name") val firstName: String,
@SerializedName("last_name") val lastName: String,
@SerializedName("is_active") val isActive: Boolean
)
// Gson maps JSON "user_id" β Kotlin id, "first_name" β firstName, etc.
If you forget
@SerializedName and the JSON key uses snake_case (user_id) but your Kotlin property uses camelCase (userId), Gson returns null β no error, just silent null. Always double-check the actual JSON field names.
06
Repository & ViewModel (Network)
π ArticleDemo
kotlin Β· ArticleRepository.kt
class ArticleRepository(
private val api: ArticleApi = RetroFitClient.articleApi
) {
// runCatching wraps the suspend call β any exception becomes Result.Failure
suspend fun getArticles(page: Int = 1, limit: Int = 20): Result<List<Article>> =
runCatching { api.getArticles(page, limit) }
suspend fun getArticleById(id: Int): Result<Article> =
runCatching { api.getArticleById(id) }
suspend fun createArticle(article: Article): Result<Article> =
runCatching { api.createArticle(article) }
suspend fun updateArticle(id: Int, article: Article): Result<Article> =
runCatching { api.updateArticle(id, article) }
suspend fun deleteArticle(id: Int): Result<Unit> =
runCatching { api.deleteArticle(id) }
}
π ArticleDemo
kotlin Β· ArticleUiState.kt
sealed class ArticleUiState {
data object Loading : ArticleUiState()
data class Success(val articles: List<Article>) : ArticleUiState()
data class Error(val message: String) : ArticleUiState()
}
π ArticleDemo
kotlin Β· ArticleViewModel.kt
class ArticleViewModel(
private val repository: ArticleRepository = ArticleRepository()
) : ViewModel() {
private val _uiState = MutableStateFlow<ArticleUiState>(ArticleUiState.Loading)
val uiState: StateFlow<ArticleUiState> = _uiState.asStateFlow()
init { loadArticles() }
fun loadArticles() {
viewModelScope.launch {
_uiState.value = ArticleUiState.Loading
repository.getArticles()
.onSuccess { articles -> _uiState.value = ArticleUiState.Success(articles) }
.onFailure { error -> _uiState.value = ArticleUiState.Error(error.message ?: "Unknown error") }
}
}
}
Collecting StateFlow in Compose
kotlin Β· Composable screen
val uiState by viewModel.uiState.collectAsState()
when (val state = uiState) {
is ArticleUiState.Loading -> CircularProgressIndicator()
is ArticleUiState.Success -> LazyColumn { items(state.articles) { ArticleCard(it) } }
is ArticleUiState.Error -> Text("Error: ${state.message}", color = Color.Red)
}
07
Reacting to HTTP Status Codes
Change the return type from T to Response<T> to inspect HTTP status codes directly instead of relying on exceptions.
kotlin
// Interface: wrap return type in Response<T>
interface ArticleApi {
@GET("posts/{id}")
suspend fun getArticleById(@Path("id") id: Int): Response<Article>
}
// Repository / ViewModel β inspect status manually
val response = api.getArticleById(id)
if (response.isSuccessful) {
val article = response.body() // nullable β the deserialized body
// handle success ...
} else {
val errorBody = response.errorBody()?.string()
when (response.code()) {
400 -> { /* Bad Request β invalid input sent */ }
401 -> { /* Unauthorized β token missing or expired, redirect to login */ }
403 -> { /* Forbidden β valid token but no permission */ }
404 -> { /* Not Found β resource does not exist */ }
500 -> { /* Internal Server Error β backend crash */ }
}
}
HTTP Status Code Reference
| Code | Name | Meaning | Action |
|---|---|---|---|
| 200 | OK | Success with body | Use response.body() |
| 201 | Created | Resource was created | Use response.body() for the created item |
| 204 | No Content | Success, empty body | Don't call .body() β it returns null |
| 400 | Bad Request | Invalid input from client | Show validation error to user |
| 401 | Unauthorized | Missing or expired token | Redirect to login screen |
| 403 | Forbidden | Valid token, but no permission | Show "Access denied" message |
| 404 | Not Found | Resource doesn't exist | Show "Not found" UI state |
| 500 | Internal Server Error | Backend crashed | Show "Try again later" + log |
runCatching vs Response<T>:
runCatching { } catches network exceptions (no internet, timeout, DNS failure) but treats every HTTP response β including 4xx/5xx β as success from its perspective. Response<T> lets you inspect the HTTP status code. Combine both for full coverage: wrap the Response<T> call in runCatching too.
08
Room Database
@Entity β data class mapped to a DB table
π VotingApp
kotlin Β· Party.kt
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "parties")
data class Party(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val name: String,
val votes: Int = 0
)
@Dao β database access interface
π VotingApp
kotlin Β· PartyDao.kt
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface PartyDao {
// Flow = non-suspend, reactive β Room emits new list whenever DB changes
@Query("SELECT * FROM parties ORDER BY name ASC")
fun getAllParties(): Flow<List<Party>>
@Insert
suspend fun insert(party: Party)
// Custom SQL UPDATE via @Query (more flexible than @Update)
@Query("UPDATE parties SET votes = votes + :amount WHERE id = :partyId")
suspend fun updateVotes(partyId: Int, amount: Int)
@Query("UPDATE parties SET votes = 0 WHERE id = :partyId")
suspend fun resetVotes(partyId: Int)
// Standard convenience annotations also available:
// @Update suspend fun update(party: Party)
// @Delete suspend fun delete(party: Party)
}
@Database β singleton room database
π VotingApp
kotlin Β· VotingDatabase.kt
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [Party::class], version = 1)
abstract class VotingDatabase : RoomDatabase() {
abstract fun partyDao(): PartyDao
companion object {
@Volatile // ensures visibility across threads
private var INSTANCE: VotingDatabase? = null
fun get(context: Context): VotingDatabase =
INSTANCE ?: synchronized(this) { // double-checked locking for thread safety
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
VotingDatabase::class.java,
"voting.db"
)
.addCallback(object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// Seed data β runs only on first database creation
db.execSQL("INSERT INTO parties (name, votes) VALUES ('Party A', 0)")
db.execSQL("INSERT INTO parties (name, votes) VALUES ('Party B', 0)")
}
})
.build()
.also { INSTANCE = it }
}
}
}
ViewModel β Flow β StateFlow pattern
π VotingApp
kotlin Β· CounterViewModel.kt
class CounterViewModel(private val repository: VotingRepository) : ViewModel() {
// Convert DAO Flow into a StateFlow the UI can collect
val uiState: StateFlow<CountUiState> = repository.getAllParties()
.map { parties -> CountUiState(parties = parties, isLoading = false) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), // 5s grace before stopping
initialValue = CountUiState(isLoading = true)
)
fun addVote(partyId: Int) {
viewModelScope.launch { repository.updateVotes(partyId, 1) }
}
fun removeVote(partyId: Int) {
viewModelScope.launch { repository.updateVotes(partyId, -1) }
}
// ViewModelProvider.Factory β inject dependencies without Hilt/Koin
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val app = this[APPLICATION_KEY]!!
val dao = VotingDatabase.get(app).partyDao()
CounterViewModel(VotingRepositoryImpl(dao))
}
}
}
}
Rule of thumb: DAO reads return
Flow<T> (non-suspend, reactive stream). DAO writes are suspend fun. Never call suspend functions from the main thread outside a coroutine scope β it will crash.
09
Room + Retrofit: Offline-First Pattern
The UI always observes Room's Flow as the single source of truth. Retrofit only refreshes the data in the background. If the network fails, the cached Room data stays visible.
kotlin Β· ArticleRepository.kt (offline-first)
class ArticleRepository(
private val api: ArticleApi,
private val dao: ArticleDao
) {
// UI observes this Flow β Room auto-pushes updates whenever data changes
fun getArticles(): Flow<List<Article>> = dao.getAllArticles()
// Called from viewModelScope.launch β refreshes cache from network
suspend fun refreshArticles() {
runCatching { api.getArticles() }
.onSuccess { articles ->
dao.deleteAll() // clear stale data
dao.insertAll(articles) // insert fresh data from network
}
// On failure: Room data remains intact, UI continues working offline
}
}
kotlin Β· ViewModel
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {
// Always backed by Room β survives network failures
val articles: StateFlow<List<Article>> = repository.getArticles()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
init {
// Kick off a background network refresh on startup
viewModelScope.launch { repository.refreshArticles() }
}
}
10
Gotchas, Quick Tables & Reference
Common Mistakes
- Forgot
<uses-permission android:name="android.permission.INTERNET"/>in AndroidManifest.xml β silent network failure or crash BASE_URLdoes not end with/β Retrofit path resolution silently breaks- Used
kapt(libs.androidx.room.compiler)instead ofksp(...)β build error on modern Android projects - Called a
suspend funfrom the main thread without a coroutine scope β app crashes with IllegalStateException - Typo in Room
@Querycolumn or table name β runtime crash (NOT a compile error) - Missing
@SerializedNamewhen JSON key usessnake_casebut Kotlin property usescamelCaseβ Gson silently returnsnull - Returning
Tfrom the API interface instead ofResponse<T>when you need to check HTTP status codes - Making a DAO read function
suspendinstead of returningFlow<T>β it becomes a one-shot call instead of a reactive stream
Flow vs suspend β When to Use Each
| Use Case | Return Type | Notes |
|---|---|---|
| Read data that should auto-update the UI | Flow<List<T>> | DAO reads; not suspend |
| Write / Insert / Update / Delete in DB | suspend fun | DAO writes |
| One-shot network request | suspend fun | All Retrofit calls |
| Network call where you need HTTP code | suspend fun : Response<T> | Check isSuccessful + .code() |
| Error-safe network call | Result<T> via runCatching | Use onSuccess / onFailure |
StateFlow Quick Reference
kotlin
// Declare in ViewModel
private val _state = MutableStateFlow<MyState>(MyState.Loading)
val state: StateFlow<MyState> = _state.asStateFlow() // expose read-only
// Update from ViewModel
_state.value = MyState.Success(data)
// Collect in Compose
val s by viewModel.state.collectAsState()
// Convert Room Flow to StateFlow
val list = repository.getItems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
Retrofit Annotations Cheat Card
| Annotation | Where | Effect |
|---|---|---|
@GET / @POST / @PUT / @PATCH / @DELETE | On function | HTTP verb + relative URL |
@Path("x") | Parameter | Replaces {x} in URL |
@Query("k") | Parameter | Appends ?k=value to URL |
@Body | Parameter | Serializes as JSON request body |
@Header("k") | Parameter | Adds header dynamically per call |
@Headers([...]) | On function | Static headers baked into the call |
@FormUrlEncoded + @Field | Function + param | Sends application/x-www-form-urlencoded body |
@Multipart + @Part | Function + param | Sends multipart/form-data (file upload) |
Room Annotations Cheat Card
| Annotation | Where | Effect |
|---|---|---|
@Entity(tableName = "...") | data class | Maps class to a DB table |
@PrimaryKey(autoGenerate = true) | Property | Auto-incrementing primary key |
@ColumnInfo(name = "...") | Property | Custom column name (like @SerializedName but for DB) |
@Ignore | Property | Room ignores this field (not stored in DB) |
@Dao | interface | Marks as DAO β Room generates the implementation |
@Insert | suspend fun | Insert one or many entities |
@Update | suspend fun | Update by primary key match |
@Delete | suspend fun | Delete by primary key match |
@Query("SQL") | fun / suspend fun | Custom SQL; non-suspend + Flow for reactive reads |
@Database(entities=[...], version=N) | abstract class | Defines the database schema and version |