Irbisa · Cheatsheet

Android Persistence

Room, DataStore, SharedPreferences, EncryptedSharedPreferences, file storage

Middle Developer · 24 questions

  1. 01

    What ways are there to store data on Android, and when do you use each?

    Pick by the shape of the data: DataStore for small settings, Room for structured data you query, app-private files for blobs and documents, MediaStore or the system picker for files the user should see, and the Keystore for the keys that encrypt the rest.

  2. 02

    What is the difference between filesDir, cacheDir, noBackupFilesDir and getExternalFilesDir()?

    filesDir, cacheDir and noBackupFilesDir are private internal storage; getExternalFilesDir() is app-specific but on the shared volume.

  3. 03

    SharedPreferences: what is the difference between commit() and apply(), and is it thread-safe?

    commit() writes to disk synchronously on the calling thread and returns true or false; apply() updates the in-memory map at once, writes to disk in the background and reports nothing.

  4. 04

    What is Room, what are its main parts, and why use it instead of raw SQLite?

    Room is the Jetpack layer over SQLite: you describe tables with @Entity, queries with @Dao and the database with @Database, and Room generates the code and checks your SQL at compile time.

    @Entity(tableName = "note")data class NoteEntity(  @PrimaryKey(autoGenerate = true) val id: Long = 0,  val title: String,  val createdAt: Long)…
  5. 05

    After add() the new task appears only when the user leaves the screen and comes back. What is wrong with this code?

    @Daointerface TaskDao {  @Query("SELECT * FROM task ORDER BY dueAt")  suspend fun getAll(): List<TaskEntity>   @Insert  suspend fun insert(task: TaskEntity)} class TaskViewModel(private val dao: TaskDao) : ViewModel() {  val tasks = MutableStateFlow(emptyList<TaskEntity>())   init { viewModelScope.launch { tasks.value = dao.getAll() } }   fun add(title: String) = viewModelScope.launch { dao.insert(TaskEntity(title = title)) }}

    getAll() is a one-shot read: it runs once in init and nothing asks again, so the insert reaches the database but not the screen.

  6. 06

    Why replace SharedPreferences with DataStore, and when do you choose Preferences DataStore over Proto DataStore?

    DataStore fixes what SharedPreferences gets wrong: reads are a Flow, writes are suspending atomic transactions, and failures arrive as exceptions instead of being dropped.

    @Serializabledata class UserSettings(val theme: String = "system", val notifications: Boolean = true) object UserSettingsSerializer : Serializer<UserSettings> {  override val defaultValue = UserSettings()…
  7. 07

    This code reads the theme from DataStore before showing the UI. What is wrong with it, and how should it be done?

    val Context.settings by preferencesDataStore(name = "settings")val THEME = stringPreferencesKey("theme") class MainActivity : ComponentActivity() {  override fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    val theme = runBlocking { settings.data.first()[THEME] } ?: "system"    setContent { AppTheme(theme) { App() } }  }}

    runBlocking makes the main thread wait for a disk read and a parse during startup, which is exactly the blocking DataStore was built to remove and a source of startup jank or ANRs.

  8. 08

    Find the bug: the app crashes the second time the user opens the Settings screen.

    class SettingsRepository(context: Context) {  private val dataStore = PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("settings") }   val theme: Flow<String> = dataStore.data.map { it[THEME] ?: "system" }   suspend fun setTheme(value: String) {    dataStore.edit { it[THEME] = value }  }} class SettingsViewModel(app: Application) : AndroidViewModel(app) {  private val repo = SettingsRepository(app)  val theme = repo.theme.stateIn(viewModelScope, SharingStarted.Eagerly, "system")}

    Every SettingsRepository builds its own DataStore over settings.preferences_pb.

  9. 09

    Why does Room refuse queries on the main thread, and on which threads do suspend DAO methods, Flow queries and withTransaction run?

    A query is disk I/O that can take tens of milliseconds, so Room throws IllegalStateException: Cannot access database on the main thread for blocking DAO calls.

  10. 10

    What are Room TypeConverters, and what are their limitations?

    SQLite stores only integers, reals, text and blobs, so for other types such as Instant or a list you write a @TypeConverter pair that maps the value to a supported type and back, and register it with @TypeConverters.

    class Converters {  @TypeConverter fun fromInstant(value: Instant?): Long? = value?.toEpochMilli()  @TypeConverter fun toInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)   // Fine to read back, impossible to query on  @TypeConverter fun fromTags(tags: List<String>): String = Json.encodeToString(tags)…
  11. 11

    How do you model one-to-many and many-to-many relations in Room, and why does such a query need @Transaction?

    You declare the shape with @Embedded for the parent and @Relation for the children, and Room runs one query per side and joins the results in memory.

    @Entity(  tableName = "book",  foreignKeys = [ForeignKey(    entity = AuthorEntity::class, parentColumns = ["id"],    childColumns = ["authorId"], onDelete = ForeignKey.CASCADE  )],…
  12. 12

    What does OnConflictStrategy.REPLACE actually do, and why is @Upsert usually the better choice?

    REPLACE deletes the conflicting row and inserts a new one; it is not an update.

    @Entity(tableName = "contact")data class ContactEntity(  @PrimaryKey val remoteId: String,    // the server id is the key, so @Upsert can match it  val name: String,  val isFavourite: Boolean = false     // local only)…
  13. 13

    How do Room migrations work, and what does the crash "Room cannot verify the data integrity" mean?

    Room stores the schema version and a schema hash in the database file.

    @Database(  entities = [TaskEntity::class],  version = 5,  exportSchema = true,  autoMigrations = [AutoMigration(from = 4, to = 5, spec = AppDatabase.RenameDue::class)])…
  14. 14

    How do you test a Room DAO, and how do you test a migration?

    Test a DAO against a real in-memory database rather than a mock, because the value of Room is the SQL it generates.

    @RunWith(AndroidJUnit4::class)class NoteDaoTest {  private val db = Room.inMemoryDatabaseBuilder(    ApplicationProvider.getApplicationContext(), AppDatabase::class.java  ).build()…
  15. 15

    During a sync the list Flow keeps re-emitting and the screen flickers, though only a few rows changed. Why, and how do you fix it?

    suspend fun applyRemote(rows: List<TaskEntity>) {  rows.forEach { dao.upsert(it) }} @Daointerface TaskDao {  @Upsert suspend fun upsert(task: TaskEntity)   @Query("SELECT * FROM task ORDER BY dueAt")  fun observeAll(): Flow<List<TaskEntity>>}

    Each upsert outside a transaction is its own transaction, and Room's invalidation is per table: every commit tells every observer of task to re-run its whole query.

  16. 16

    Find the bug: when the user taps + quickly, the cart quantity sometimes ends up lower than the number of taps.

    @Daointerface CartDao {  @Query("SELECT * FROM cart_item WHERE productId = :id")  suspend fun find(id: Long): CartItemEntity?   @Upsert  suspend fun upsert(item: CartItemEntity)} class CartRepository(private val dao: CartDao) {  suspend fun addOne(productId: Long) {    val current = dao.find(productId)?.quantity ?: 0    dao.upsert(CartItemEntity(productId, quantity = current + 1))  }}// CartViewModel: fun onPlusClick(id: Long) = viewModelScope.launch { repo.addOne(id) }

    addOne() reads the quantity and writes current + 1 as two separate statements, so two taps can both read 3 and both write 4: one increment is lost (a lost update).

  17. 17

    How do you store an auth token securely on Android, and what replaced EncryptedSharedPreferences?

    Generate an AES key in the Android Keystore, encrypt the token with it and store only the ciphertext in DataStore, a file or Room.

    private const val ALIAS = "auth" private fun key(): SecretKey {  val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }  (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }  return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {…
  18. 18

    What is scoped storage, and how do you save an exported PDF where the user can find it without WRITE_EXTERNAL_STORAGE?

    Since Android 10 and 11, apps cannot write arbitrary paths on shared storage; they get private directories, the MediaStore collections and the system file picker.

    // Option 1: the user picks the destination, no permission at allval createPdf = registerForActivityResult(  ActivityResultContracts.CreateDocument("application/pdf")) { uri ->  if (uri != null) lifecycleScope.launch(Dispatchers.IO) {    contentResolver.openOutputStream(uri)?.use { exporter.writeTo(it) }…
  19. 19

    A JSON file the app writes sometimes comes back truncated after a crash. Why, and how do you write files safely?

    FileOutputStream(target) truncates the file before the first byte is written, so a crash mid-write leaves half a file and no old copy.

    // wrong: opening the target truncates it firstFile(context.filesDir, "cart.json").writeText(json) // right: backup + sync, the last good bytes survive a failed writeprivate val cartFile = AtomicFile(File(context.filesDir, "cart.json"))…
  20. 20

    How do you build an offline cache with Room and Retrofit where the database is the single source of truth?

    The UI observes only Room: the network writes responses into Room and the Room Flow delivers them, so the screen shows cached data instantly, works offline and has a single update path.

    class ArticleRepository(  private val api: ArticleApi,  private val dao: ArticleDao,  private val db: AppDatabase) {  // The only thing the UI ever reads…
  21. 21

    The user logs out. What must you clear besides the token, and in what order?

    Clearing the token only stops new requests being authorised; every local copy of the previous user's data stays on the device and the next person to sign in will see it.

  22. 22

    What is write-ahead logging (WAL) in SQLite, and what does it change for a Room app?

    In WAL mode SQLite appends committed changes to a separate -wal file instead of rewriting database pages in place, so readers keep reading the last committed state while a writer writes.

  23. 23

    How do indexes work in SQLite, and how would you fix a Room query that slowed down as the table grew?

    An index is a separate B-tree sorted by the indexed columns, so SQLite can find matching rows in logarithmic time instead of reading the whole table.

    @Entity(  tableName = "message",  indices = [    Index(value = ["chatId", "createdAt"]),      // filter by chat, order by time    Index(value = ["remoteId"], unique = true)  ]…
  24. 24

    Why does app data come back after a reinstall, and why can a restored app crash while decrypting its own data?

    Auto Backup is on by default: it copies filesDir, databases and SharedPreferences to the user's Google account and restores them on reinstall or on a new phone.

    private val sealed = File(context.noBackupFilesDir, "session.bin")   // never backed up fun loadRefreshToken(): String? = try {  if (!sealed.exists()) null else crypto.decrypt(sealed.readBytes()).decodeToString()} catch (e: GeneralSecurityException) {   // key missing: data restored from another device  sealed.delete()…