Room is perfect for lists of things: items, expenses, bus stops. But much of what an app remembers is small and simple: "dark theme on", "show items as a grid", "last search was lagos", "the user has finished the tutorial". Creating a database table for a single true/false value would be overkill.
Jetpack DataStore is the modern way to save small key-value settings. It is asynchronous, built on coroutines and Flow, safe to use from any thread, and replaces the old SharedPreferences. In this lesson you will add a persistent layout toggle to an app, understand why DataStore is built the way it is, and plan the unit's capstone: the Flight Search project, which combines Room (the airports) and DataStore (the user's last search).
This lesson covers the four items of Pathway 3, Unit 6 of the official Android Basics with Compose course: Introduction to DataStore, Preferences DataStore, Save preferences locally with DataStore and Project: Create a flight search app.
By the end you will be able to:
- Choose among DataStore, Room,
SharedPreferencesand plain files. - Add the Preferences DataStore dependency and create a single DataStore instance.
- Define typed keys, read preferences as a
Flow, and write them withedit { }. - Handle
IOExceptionwhen reading. - Build the repository, ViewModel and Compose UI that save and restore a layout choice.
- Explain Proto DataStore and when it matters.
- Plan the Flight Search project.
Big idea: a preference is a small fact that must outlive the app process. DataStore stores it, exposes it as a
Flow, and your UI simply collects.
- Flow,
map,catchandstateIn(lesson 33). - Repositories and the app container (lesson 30).
- ViewModel and UI state (lessons 24 and 25).
- Room basics (lesson 34), used by the project at the end.
- Coroutines (lesson 28). DataStore writes are
suspendfunctions.
| Storage | Best for | Strengths | Weaknesses |
|---|---|---|---|
| Preferences DataStore | Small key-value settings | Async, Flow-based, transactional, no main-thread risk | No queries, no relations, not for large data |
| Proto DataStore | Small typed objects with a schema | Type safety, versioning | Needs a schema definition and code generation |
| Room | Lists of structured records | Queries, relationships, observing | Setup overhead for tiny data |
SharedPreferences (legacy) |
Existing old code | Simple API | Synchronous reads can block the UI thread, no error signalling, no transactional guarantees |
| Files | Documents, images, exports | Full control | You manage format and errors |
| Remote server | Data that must follow the user across devices | Sync | Needs network and accounts |
A rule of thumb: if you would be happy to store it in a Map<String, Any>, use DataStore. If you want to ask questions like "all items where...", use Room.
DataStore is not for secrets such as passwords or tokens: its files are plain data in your app's private folder, protected by the operating system's sandbox, not encrypted by DataStore. Use the platform's encrypted storage and keystore facilities for secrets.
Why DataStore beats SharedPreferences
Older code uses SharedPreferences. You will meet it in existing projects, so know its problems:
- Reads are synchronous. A first read can touch the disk on the main thread and cause jank.
- It offers no way to report errors from reading or parsing the file.
- Writes with
apply()are fire-and-forget, so you cannot tell when they finished or whether they failed. - It has no built-in way to observe changes as a stream of values.
DataStore fixes each of these with coroutines and Flow. It also writes transactionally: an edit { } block either fully succeeds or leaves the data unchanged.
UI --event--> ViewModel --suspend--> Repository --edit{}--> DataStore (file on disk)
UI <--state-- ViewModel <---Flow---- Repository <--data---- DataStore
Look up the current stable version of datastore-preferences on the release page (https://developer.android.com/jetpack/androidx/releases/datastore) and add it to the version catalog:
[versions]
datastore = "<latest stable>"
[libraries]
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
dependencies {
implementation(libs.androidx.datastore.preferences)
}
Sync. The official codelab used a direct dependency string (androidx.datastore:datastore-preferences:<version>) in the module file; the version catalog does the same job in one place.
We will add a persistent list or grid layout toggle to a small catalogue app (such as the Affirmations app of lesson 21, or Dessert Release in the official codelab). The preference is a single boolean: isLinearLayout.
Create exactly one DataStore
// InventoryApplication.kt or a top-level file
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
private const val LAYOUT_PREFERENCE_NAME = "layout_preferences"
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = LAYOUT_PREFERENCE_NAME
)
This property delegate creates the DataStore the first time it is used and returns the same instance afterwards. Two rules matter:
- Declare it once, at the top level of a Kotlin file, never inside a class or function.
- Never create two DataStore instances for the same file name in the same process. That breaks its single-writer guarantee and throws
IllegalStateException: There are multiple DataStores active for the same file.
The repository
package com.example.catalog.data
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import java.io.IOException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
class UserPreferencesRepository(
private val dataStore: DataStore<Preferences>
) {
private companion object {
val IS_LINEAR_LAYOUT = booleanPreferencesKey("is_linear_layout")
const val TAG = "UserPreferencesRepo"
}
val isLinearLayout: Flow<Boolean> = dataStore.data
.catch { e ->
if (e is IOException) {
Log.e(TAG, "Error reading preferences.", e)
emit(emptyPreferences())
} else {
throw e
}
}
.map { preferences ->
preferences[IS_LINEAR_LAYOUT] ?: true
}
suspend fun saveLayoutPreference(isLinearLayout: Boolean) {
dataStore.edit { preferences ->
preferences[IS_LINEAR_LAYOUT] = isLinearLayout
}
}
}
Step through it:
Keys. A preference is identified by a typed key made with one of the helper functions: booleanPreferencesKey, intPreferencesKey, longPreferencesKey, floatPreferencesKey, doublePreferencesKey, stringPreferencesKey and stringSetPreferencesKey. The type is part of the key, so reading with the wrong type is a compile error. The key name ("is_linear_layout") is the label on disk: never change it later or users lose the saved value.
Reading. dataStore.data is a Flow<Preferences>. It emits the current preferences immediately and again every time any preference is edited, so your UI updates automatically. We:
catchanIOException(a disk problem reading the file) and emit empty preferences, so the app falls back to defaults instead of crashing. Any other exception is rethrown, which keepsCancellationExceptionbehaving correctly (lesson 28).mapthePreferencesto the one value we want, using the elvis operator?: trueto supply a default when the key has never been written. The first time the app runs, nothing is stored, so the default matters.
Writing. dataStore.edit { } is a suspend function. The lambda receives mutable preferences, you assign with the key as an index (preferences[KEY] = value), and DataStore commits the whole block atomically. To clear a value use preferences.remove(KEY); to clear everything use preferences.clear().
Notice how little code that is, and how it matches the Repository pattern from lesson 30: the repository hides DataStore from the rest of the app.
Container or Application
class CatalogApplication : Application() {
lateinit var userPreferencesRepository: UserPreferencesRepository
override fun onCreate() {
super.onCreate()
userPreferencesRepository = UserPreferencesRepository(dataStore)
}
}
Register it with android:name=".CatalogApplication" in the manifest (lesson 30). If you already have an AppContainer, add userPreferencesRepository there instead; the structure is the same.
ViewModel
package com.example.catalog.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.catalog.CatalogApplication
import com.example.catalog.data.UserPreferencesRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class CatalogUiState(val isLinearLayout: Boolean = true)
class CatalogViewModel(
private val userPreferencesRepository: UserPreferencesRepository
) : ViewModel() {
val uiState: StateFlow<CatalogUiState> =
userPreferencesRepository.isLinearLayout
.map { CatalogUiState(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = CatalogUiState()
)
fun selectLayout(isLinearLayout: Boolean) {
viewModelScope.launch {
userPreferencesRepository.saveLayoutPreference(isLinearLayout)
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[APPLICATION_KEY] as CatalogApplication
CatalogViewModel(application.userPreferencesRepository)
}
}
}
}
Observe the full loop, which is the whole lesson in one picture:
- The user taps the toggle: the UI calls
viewModel.selectLayout(false). - The ViewModel launches a coroutine and calls the repository's
saveLayoutPreference. - DataStore writes to disk and then emits new
data. isLinearLayoutmaps it,stateInpublishes it, andcollectAsStateWithLifecycle()hands it to Compose.- The screen recomposes with the other layout.
Notice that the UI never sets the layout directly. It only reads state and reports events (unidirectional data flow, lesson 24). That is why the layout survives rotation, process death and a full phone restart.
The initialValue is shown for a split second before the first emission arrives from disk. Choose it so there is no ugly flash: using the same default as the repository (true) avoids a visible jump for first-time users.
The toggle in Compose
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun CatalogApp(viewModel: CatalogViewModel = viewModel(factory = CatalogViewModel.Factory)) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Catalog") },
actions = {
IconButton(onClick = { viewModel.selectLayout(!uiState.isLinearLayout) }) {
Icon(
imageVector = if (uiState.isLinearLayout) Icons.Filled.GridView
else Icons.Filled.ViewList,
contentDescription = if (uiState.isLinearLayout) "Switch to grid" else "Switch to list"
)
}
}
)
}
) { innerPadding ->
if (uiState.isLinearLayout) CatalogList(Modifier.padding(innerPadding))
else CatalogGrid(Modifier.padding(innerPadding))
}
}
(GridView and ViewList come from the extended icon set; add material-icons-extended or use your own vector assets. CenterAlignedTopAppBar needs @OptIn(ExperimentalMaterial3Api::class) in many Material 3 versions.) The two content composables are the LazyColumn and LazyVerticalGrid from lesson 21. Notice the content description changes with the state, which tells screen-reader users what the button will do (lesson 47).
Run it. Switch to grid, close the app from recents, reopen: the grid is still there. Now stop the app with adb shell am kill or restart the emulator: still there. That is persistence.
Real apps often need several settings. Two common approaches:
// Approach 1: many keys in one DataStore, one Flow per setting
val darkTheme: Flow<Boolean> = dataStore.data.map { it[DARK_THEME] ?: false }
val username: Flow<String> = dataStore.data.map { it[USERNAME] ?: "" }
// Approach 2: map once into a settings object
data class UserSettings(val darkTheme: Boolean = false, val username: String = "", val fontScale: Float = 1f)
val settings: Flow<UserSettings> = dataStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { prefs ->
UserSettings(
darkTheme = prefs[DARK_THEME] ?: false,
username = prefs[USERNAME] ?: "",
fontScale = prefs[FONT_SCALE] ?: 1f
)
}
Approach 2 is usually cleaner: one stream, one immutable object, and a single place for defaults. Use distinctUntilChanged() if a particular consumer should not be woken by unrelated changes. For several writes that belong together, put them in one edit { } block so they are applied atomically.
Proto DataStore stores a typed object instead of loose keys. You define a schema (traditionally with Protocol Buffers; it can also use a Kotlin serializable class with a custom Serializer), and you read and write whole objects safely, with defaults and versioning.
| Preferences DataStore | Proto DataStore | |
|---|---|---|
| Schema | None; keys at runtime | Declared up front |
| Type safety | Per key | Whole object |
| Setup | Minimal | A serializer (and a schema if using protobuf) |
| Use for | A few independent settings | Structured settings, nested data, strong migration needs |
For this course Preferences DataStore is enough. Reach for Proto DataStore when your settings become a structured document that you want to evolve safely, and read the current official guide first, since its setup is more involved.
Migrating from SharedPreferences
If an existing app already stores settings in SharedPreferences, DataStore can import them once with a SharedPreferencesMigration, passed to the delegate:
val Context.dataStore by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "old_prefs_file_name"))
}
)
The migration runs before the first read, copies the values, and (by default) removes them from the old file, so users keep their settings across the upgrade.
Because the repository takes a DataStore<Preferences> in its constructor, a test can make its own DataStore backed by a temporary file. The androidx.datastore:datastore-preferences-core artifact offers PreferenceDataStoreFactory:
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class UserPreferencesRepositoryTest {
@get:Rule val tmp = TemporaryFolder()
private fun createDataStore(scope: CoroutineScope): DataStore<Preferences> =
PreferenceDataStoreFactory.create(scope = scope) { tmp.newFile("test.preferences_pb") }
@Test
fun defaultsToLinearLayout() = runTest {
val repo = UserPreferencesRepository(createDataStore(backgroundScope))
assertEquals(true, repo.isLinearLayout.first())
}
@Test
fun savedChoiceIsReadBack() = runTest {
val repo = UserPreferencesRepository(createDataStore(backgroundScope))
repo.saveLayoutPreference(false)
assertFalse(repo.isLinearLayout.first())
}
}
backgroundScope is provided by runTest; coroutines launched in it are cancelled automatically when the test ends, which is what DataStore needs for its internal work. Check the current DataStore testing documentation if the factory signature in your version differs.
The capstone of Unit 6 is the Flight Search app. This is a planning-first project: read the brief, design on paper, then build.
The brief. The app has a search box. As the user types an airport name or its three-letter IATA code, a list of suggestions appears (ordered by how many passengers each airport handles). Tapping a suggestion shows flights from that airport to every other airport in the database. Each flight can be marked as a favourite, and when the search box is empty the user sees their saved favourites. The text in the search box is remembered the next time the app starts.
Data. You need a table of airports (id, name, IATA code, passenger count) and a table of favourites (id, departure code, destination code). Create your own sample airports data; the official course supplies a starter database, which you should obtain from the official pathway page.
| Requirement | Tool | Lesson |
|---|---|---|
| Airports and favourites tables | Room | 34 |
| Autocomplete query | @Query with LIKE, bound parameter |
32, 34 |
| Flights from X to all others | SELECT * FROM airport WHERE iata_code <> :code |
32 |
| React to text | debounce, flatMapLatest |
33 |
| Remember last search text | Preferences DataStore | this lesson |
| Architecture | Repository, container, ViewModel, stateIn |
30 |
| Screens | One screen, LazyColumn, search bar |
21, 26 |
Design questions to answer before coding:
- Is a flight a table row? (Hint: no. It is a pair of airports, computed, not stored. Only favourites are stored.)
- What happens when the search text is empty: suggestions or favourites?
- When the user picks an airport, does the text field keep their typed text or fill in the full name? Where is that state kept?
- How do you avoid a flash of "no favourites" before the first database read finishes?
Exercise 1: Remember a username and a theme
Extend the repository to store a username (String) and darkTheme (Boolean) with defaults. Expose one Flow<UserSettings>. Add setUsername and setDarkTheme. Then write a test that proves the two writes do not overwrite each other.
data class UserSettings(val username: String = "", val darkTheme: Boolean = false)
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
private companion object {
val USERNAME = stringPreferencesKey("username")
val DARK_THEME = booleanPreferencesKey("dark_theme")
}
val settings: Flow<UserSettings> = dataStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { UserSettings(it[USERNAME] ?: "", it[DARK_THEME] ?: false) }
suspend fun setUsername(name: String) {
dataStore.edit { it[USERNAME] = name.trim() }
}
suspend fun setDarkTheme(enabled: Boolean) {
dataStore.edit { it[DARK_THEME] = enabled }
}
}
@Test
fun twoWrites_doNotOverwriteEachOther() = runTest {
val repo = SettingsRepository(createDataStore(backgroundScope))
repo.setUsername("Ada")
repo.setDarkTheme(true)
assertEquals(UserSettings("Ada", true), repo.settings.first())
}
Each edit changes only its own key, leaving the rest of the preferences intact.
Exercise 2: Debug the missing default
Users report that on first launch the app crashes with a NullPointerException when reading a preference. The code is:
val count: Flow<Int> = dataStore.data.map { it[COUNT]!! }
Explain and fix it. Then add a function that increments the count safely.
A key that has never been written has no value, so it[COUNT] returns null and !! throws on first launch. Give a default with the elvis operator: it[COUNT] ?: 0.
For the increment, read and write in one edit block, so the read-modify-write is atomic:
suspend fun increment() {
dataStore.edit { prefs ->
prefs[COUNT] = (prefs[COUNT] ?: 0) + 1
}
}
Reading with data.first() and then writing in a second call would let two quick taps both read the same old value and lose an increment.
Add the layout toggle to your Affirmations app from lesson 21. Kill the process with adb shell am kill and confirm the choice survives. Then use App Inspection or Device Explorer to look at the files under your app's datastore folder, and note the file name.
Mistake #1: Creating more than one DataStore for the same file. Symptom: There are multiple DataStores active for the same file. The fix: a single top-level by preferencesDataStore(...) delegate per file.
Mistake #2: Declaring the delegate inside a class. What happens: a new instance each time. The fix: top level, once.
Mistake #3: Forgetting a default. What happens: nulls or wrong first-run values. The fix: ?: default.
Mistake #4: Renaming a key. What happens: users silently lose their saved preference. The fix: treat keys as permanent, or write a migration.
Mistake #5: Not handling IOException. What happens: a corrupt or unreadable file crashes the app. The fix: catch and emit empty preferences.
Mistake #6: Swallowing all exceptions in catch. What happens: cancellation and real bugs vanish. The fix: rethrow anything that is not an IOException.
Mistake #7: Read-then-write across two calls. What happens: lost updates. The fix: do the whole change inside one edit { }.
Mistake #8: Storing big data or secrets. What happens: slow reads, and plain-text secrets. The fix: Room or files for large data; secure storage for secrets.
Mistake #9: Reading the preference with first() in the UI. What happens: the screen never updates when the value changes. The fix: collect the Flow as state.
Mistake #10: Blocking with runBlocking to "get the value". What happens: the UI thread blocks. The fix: stay in coroutines and Flow.
Question 1. When should you choose DataStore over Room?
For small, simple key-value settings with no need for queries or relationships. Room is for structured lists of records.
Question 2. Why is DataStore safer than SharedPreferences?
It is asynchronous (no main-thread blocking), exposes data as a Flow, reports errors, and applies edits transactionally.
Question 3. What does dataStore.edit { } guarantee?
The changes inside the block are applied atomically: either all succeed or none do, and it is a suspend function so it does not block the caller.
Question 4. Why must there be only one DataStore instance per file?
DataStore assumes it is the single writer of its file. Multiple instances would conflict and corrupt or contradict data, so it throws an exception if it detects them.
Question 5. What value does the first read return for a key that was never written, and how do you control it?
null. You supply a default in the mapping with the elvis operator, for example preferences[KEY] ?: defaultValue.
Go deeper: official resources
- Android Basics with Compose, Unit 6, Pathway 3: DataStore: the official pathway including Flight Search.
- Save preferences locally with DataStore codelab
- DataStore release notes
- Data layer guide
Project goal: Build a miniature Flight Search with a hand-made airports table (at least eight airports, such as LOS, ABV, PHC, KAN, ACC, LHR, JFK, DXB), a search box, favourites, and a remembered search text.
Requirements:
- Room entities
Airport(id, name, iataCode, passengers)andFavorite(id, departureCode, destinationCode), with a database prepopulated in aCallback(or by inserting on first run). - DAO queries:
searchAirports(text)matching name or code withLIKE, ordered by passengers descending;otherAirports(code);getFavorites();insertFavorite,deleteFavorite. - A DataStore preference
last_search(String) that is saved as the user types (debounced) and restored on launch. - A ViewModel producing one UI state: the search text, the suggestions, the selected departure airport (if any), the flights, and the favourite flags.
- Behaviour: empty text with no selection shows favourites; text shows suggestions; tapping a suggestion shows flights from it; a star toggles a favourite.
- A repository test for the DataStore preference and a DAO test for
searchAirports.
Stretch goal: show a small Snackbar the first time a favourite is saved.
// FlightDao.kt
@Dao
interface FlightDao {
@Query("""
SELECT * FROM airport
WHERE name LIKE '%' || :text || '%' OR iata_code LIKE '%' || :text || '%'
ORDER BY passengers DESC
""")
fun searchAirports(text: String): Flow<List<Airport>>
@Query("SELECT * FROM airport WHERE iata_code <> :code ORDER BY passengers DESC")
fun otherAirports(code: String): Flow<List<Airport>>
@Query("SELECT * FROM airport WHERE iata_code = :code LIMIT 1")
fun airportByCode(code: String): Flow<Airport?>
@Query("SELECT * FROM favorite ORDER BY id")
fun getFavorites(): Flow<List<Favorite>>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertFavorite(favorite: Favorite)
@Query("DELETE FROM favorite WHERE departure_code = :from AND destination_code = :to")
suspend fun deleteFavorite(from: String, to: String)
}
// SearchViewModel.kt (core of the state production)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
class SearchViewModel(
private val dao: FlightDao,
private val prefs: SearchPreferencesRepository
) : ViewModel() {
private val text = MutableStateFlow<String?>(null) // null until restored
private val selected = MutableStateFlow<Airport?>(null)
init {
viewModelScope.launch { text.value = prefs.lastSearch.first() } // restore once
viewModelScope.launch {
text.filterNotNull().debounce(400).collect { prefs.saveLastSearch(it) }
}
}
private val suggestions = text.filterNotNull().flatMapLatest { t ->
if (t.isBlank()) flowOf(emptyList()) else dao.searchAirports(t.trim())
}
private val flights = selected.flatMapLatest { from ->
if (from == null) flowOf(emptyList())
else dao.otherAirports(from.iataCode).map { dests -> dests.map { from to it } }
}
val uiState: StateFlow<SearchUiState> = combine(
text.filterNotNull(), suggestions, selected, flights, dao.getFavorites()
) { t, suggest, from, flightList, favorites ->
val favKeys = favorites.map { it.departureCode to it.destinationCode }.toSet()
SearchUiState(
text = t,
suggestions = suggest,
selected = from,
flights = flightList.map { (a, b) ->
FlightItem(a, b, isFavorite = (a.iataCode to b.iataCode) in favKeys)
},
showFavorites = t.isBlank() && from == null
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchUiState())
fun onTextChange(newText: String) { text.value = newText; selected.value = null }
fun onAirportClick(airport: Airport) { selected.value = airport }
fun toggleFavorite(item: FlightItem) = viewModelScope.launch {
if (item.isFavorite) dao.deleteFavorite(item.from.iataCode, item.to.iataCode)
else dao.insertFavorite(Favorite(departureCode = item.from.iataCode, destinationCode = item.to.iataCode))
}
}
// SearchPreferencesRepository.kt
class SearchPreferencesRepository(private val dataStore: DataStore<Preferences>) {
private companion object { val LAST_SEARCH = stringPreferencesKey("last_search") }
val lastSearch: Flow<String> = dataStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { it[LAST_SEARCH] ?: "" }
suspend fun saveLastSearch(text: String) {
dataStore.edit { it[LAST_SEARCH] = text }
}
}
Notes: the text flow starts as null so the UI waits for the restored value instead of flashing an empty search box (design question 4). flatMapLatest re-runs the SQL whenever the text or the selected airport changes and cancels the old query; combine merges all five streams into one immutable state; stateIn shares it. A flight is a computed pair, never a table row, which answers design question 1. The favourites table is the only flight data stored. The DAO's column names (iata_code, departure_code) assume @ColumnInfo(name = ...) on the entities. Room imports follow lesson 34 (androidx.room3 for Room 3, androidx.room for Room 2.x); confirm them in the official setup page. The tests follow the patterns in this lesson and lesson 34: PreferenceDataStoreFactory with a temporary folder for the repository, and an in-memory database for searchAirports.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 35 project”.
On github.com click New repository, name it android-lesson-35-store-preferences-with-datastore, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 35 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Where Should This Data Live?
- Step-by-Step Build 1: Add DataStore
- Step-by-Step Build 2: The Repository
- Step-by-Step Build 3: Wire It Up
- More Than One Preference
- Proto DataStore (Briefly)
- Testing
- The Project: Flight Search
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check