Skip to content
Educora
Advanced25 min13 / 14

Data: networking, Room and DataStore

Load JSON from a server with Retrofit or Ktor, run requests with coroutines in the ViewModel without freezing the screen, keep lists in a Room database and settings in DataStore.

Check yourself
In this lesson you will learn
  • Write a suspend network request with a Retrofit interface and turn JSON into a data class
  • Launch a request in viewModelScope and show loading, success and error states
  • Create an entity, a DAO and a database class in Room
  • Store small settings in Preferences DataStore

Murad opens Educora on the metro, and there is no internet. A good app does not show an empty screen at that moment: the lessons were saved on the phone beforehand, the dark-theme choice is remembered, and when the internet comes back new lessons quietly load from the server. This takes three tools: Retrofit or Ktor for the network, a Room database for lists, and DataStore for settings.

Networking: Retrofit and Ktor

First add the internet permission to AndroidManifest.xml: <uses-permission android:name="android.permission.INTERNET" />. Retrofit is the most widely used HTTP library on Android: you just write an interface, and the library generates the request code. kotlinx.serialization turns JSON into Kotlin objects. The example below loads a list of to-dos from JSONPlaceholder, a public service for testing.

Kotlin
@Serializable
data class Todo(val id: Int, val title: String, val completed: Boolean)

interface TodoApi {
    @GET("todos")
    suspend fun getTodos(): List<Todo>

    @GET("todos/{id}")
    suspend fun getTodo(@Path("id") id: Int): Todo
}

private val json = Json { ignoreUnknownKeys = true }

val todoApi: TodoApi = Retrofit.Builder()
    .baseUrl("https://jsonplaceholder.typicode.com/")
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()
    .create(TodoApi::class.java)
ignoreUnknownKeys = true quietly skips extra fields in the JSON (such as userId). baseUrl must end with /.

Ktor is the alternative made by JetBrains for Kotlin; it also works in Kotlin Multiplatform, so the same networking code can be shared between Android and iOS. In Ktor a request looks like this: client.get("https://jsonplaceholder.typicode.com/todos").body<List<Todo>>() — where client is an HttpClient set up with the ContentNegotiation plugin and json().

Kotlin
@Serializable
data class Lesson(val id: Int, val title: String, val minutes: Int)

fun main() {
    val lesson = Lesson(1, "Variables", 10)
    println(Json.encodeToString(lesson))

    val json = Json { ignoreUnknownKeys = true }
    val text = """{"id":2,"title":"Loops","minutes":15,"level":"beginner"}"""
    val loaded = json.decodeFromString<Lesson>(text)
    println(loaded)
    println("Total: ${lesson.minutes + loaded.minutes} min")
}
Expected output
{"id":1,"title":"Variables","minutes":10}
Lesson(id=2, title=Loops, minutes=15)
Total: 25 min
A check without a network: kotlinx.serialization turns an object into JSON and back. The level field is skipped because the class does not have it.

Coroutines in the ViewModel

A network request can take seconds, so we launch it as a coroutine in the ViewModel's **viewModelScope**. When the user closes the screen, the ViewModel is cleared and every coroutine in this scope is cancelled automatically — no wasted work and no memory leaks. We describe the result with a sealed state you already know from earlier lessons.

Kotlin
sealed interface TodosUiState {
    data object Loading : TodosUiState
    data class Success(val todos: List<Todo>) : TodosUiState
    data class Error(val message: String) : TodosUiState
}

class TodosViewModel(private val api: TodoApi) : ViewModel() {
    private val _uiState = MutableStateFlow<TodosUiState>(TodosUiState.Loading)
    val uiState: StateFlow<TodosUiState> = _uiState.asStateFlow()

    init { load() }

    fun load() {
        viewModelScope.launch {
            _uiState.value = TodosUiState.Loading
            _uiState.value = try {
                TodosUiState.Success(api.getTodos())
            } catch (e: IOException) {
                TodosUiState.Error("No connection")
            } catch (e: HttpException) {
                TodosUiState.Error("Server error ${e.code()}")
            }
        }
    }
}
The screen shows the three cases with when (state): a spinning indicator, the list, or an error message with a “Try again” button.
Kotlin
@Composable
fun TodosScreen(viewModel: TodosViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    when (val current = state) {
        TodosUiState.Loading -> CircularProgressIndicator()
        is TodosUiState.Success -> LazyColumn {
            items(current.todos, key = { it.id }) { todo -> Text(todo.title) }
        }
        is TodosUiState.Error -> Column {
            Text(current.message)
            Button(onClick = viewModel::load) { Text("Try again") }
        }
    }
}
On screen: first a spinning circle, then the list of to-dos; without internet, the text “No connection” and a “Try again” button.

Note that TodosViewModel takes an api parameter in its constructor, so the plain viewModel() cannot create it — you either write a small factory (ViewModelProvider.Factory) or, as in real projects, use a dependency injection library such as Hilt or Koin. There is usually one more layer between the ViewModel and the network — a repository (TodoRepository): it hides where the data comes from (the server, the database, a cache), and the ViewModel talks only to it.

Wrong: the main thread is blocked
fun load() {
    // blocks the main thread until the server answers
    val todos = runBlocking { api.getTodos() }
    _uiState.value = TodosUiState.Success(todos)
}
Right: a coroutine in viewModelScope
fun load() {
    viewModelScope.launch {
        _uiState.value = try {
            TodosUiState.Success(api.getTodos())
        } catch (e: IOException) {
            TodosUiState.Error("No connection")
        }
    }
}
On the left the screen freezes until the answer arrives, and after a few seconds the system may show an “Application Not Responding” (ANR) dialog. The left version also does not catch errors.

Room: a database on the device

Room is the official library built on top of SQLite. It has three parts: an Entity — a data class that is a row of a table; a DAO — an interface that describes SQL queries as functions; and the Database — an abstract class that ties them together. The code is generated at compile time by the KSP plugin, and SQL mistakes are caught right then.

Kotlin
@Entity(tableName = "lessons")
data class LessonEntity(
    @PrimaryKey val id: Int,
    val title: String,
    val done: Boolean = false
)

@Dao
interface LessonDao {
    @Query("SELECT * FROM lessons ORDER BY id")
    fun observeAll(): Flow<List<LessonEntity>>

    @Upsert
    suspend fun upsertAll(lessons: List<LessonEntity>)

    @Query("UPDATE lessons SET done = 1 WHERE id = :id")
    suspend fun markDone(id: Int)
}

@Database(entities = [LessonEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun lessonDao(): LessonDao
}
The database is created once: Room.databaseBuilder(context, AppDatabase::class.java, "educora.db").build(). A query that returns a Flow emits a new list every time the table changes.

DataStore: for settings

Kotlin
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

private val DARK_THEME = booleanPreferencesKey("dark_theme")

class SettingsRepository(private val context: Context) {
    val darkTheme: Flow<Boolean> =
        context.dataStore.data.map { prefs -> prefs[DARK_THEME] ?: false }

    suspend fun setDarkTheme(enabled: Boolean) {
        context.dataStore.edit { prefs -> prefs[DARK_THEME] = enabled }
    }
}
preferencesDataStore is declared once, at the top level of a file; creating a second instance for the same file causes an error.
What to storeWhere
theme, language, notification switchesDataStore
lessons, results, lists that need searching and sortingRoom
images and large filesthe app's files folder
the account and progress that must match on every devicethe server
  1. 1
    Open App Inspection

    Run the app on an emulator and choose View › Tool Windows › App Inspection.

  2. 2
    Look at the database

    In the Database Inspector tab, open educora.db and the lessons table. The table updates live, and you can also write and test an SQL query there.

  3. 3
    Watch the requests

    In the Network Inspector tab you will see every HTTP request, how long it took and the JSON the server returned.

search text in the whole project (Mac: Cmd+Shift+F)Ctrl+Shift+F
complete the statement: closes brackets and braces for you (Mac: Cmd+Shift+Return)Ctrl+Shift+Enter
duplicate the line (Mac: Cmd+D)Ctrl+D
comment or uncomment the line (Mac: Cmd+/)Ctrl+/

Key points

  • Networking needs the INTERNET permission in the manifest; Retrofit generates request code from suspend functions in an interface.
  • Requests run inside viewModelScope.launch; they are cancelled automatically when the screen closes.
  • Describe the result with a Loading / Success / Error sealed state and catch errors with try/catch.
  • Room: Entity + DAO + Database; a query that returns Flow updates the screen by itself when the table changes.
  • Small settings live in DataStore; the local database is the screen's single source of truth.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
Which permission must be in the manifest for the app to use the internet?