Android Persistence
Irbisa · cheatsheetSeptember 13, 2026

Android Persistence

Room, DataStore, SharedPreferences, EncryptedSharedPreferences, file storage

Middle Developer20 itemscompressed for a skim
  1. 01

    What persistence options are available on Android and when do you choose each?

    Easy

    Pick the smallest tool that fits the shape of the data: key-value settings, structured rows, or files.

    // DataStore — the default for key-value state
    val Context.dataStore by preferencesDataStore(name = "app")
    val ONBOARDED = booleanPreferencesKey("onboarded")
    
    val onboarded: Flow<Boolean> = context.dataStore.data.map { it[ONBOARDED] ?: false }
    suspend fun setOnboarded(v: Boolean) { context.dataStore.edit { it[ONBOARDED] = v } }
    …
  2. 02

    What is Room? Show a basic Database, DAO, and migration.

    Medium

    Room is a compile-time SQL layer over SQLite: you declare the schema and the queries, and it generates the implementation.

    @Entity(tableName = "note", indices = [Index("createdAt")])
    data class NoteEntity(
      @PrimaryKey(autoGenerate = true) val id: Long = 0,
      val title: String,
      val body: String,
      val createdAt: Long
    …
  3. 03

    What are TypeConverters in Room? When are they needed?

    Medium

    Room natively understands only what SQLite stores: Int, Long, Float, Double, String and ByteArray.

    enum class Priority { LOW, MEDIUM, HIGH }
    
    class Converters {
      // Date <-> Long
      @TypeConverter fun fromDate(value: Date?): Long? = value?.time
      @TypeConverter fun toDate(value: Long?): Date? = value?.let { Date(it) }
    …
  4. 04

    Preferences DataStore vs Proto DataStore — what's the difference?

    Medium

    Both DataStore flavors are async and transactional — the difference is whether a schema is enforced.

    // Preferences DataStore — untyped file, typed keys
    val Context.settings by preferencesDataStore("settings")
    val THEME = stringPreferencesKey("theme")
    
    val theme: Flow<String> = context.settings.data
      .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }
    …
  5. 05

    How do you do offline-first sync between local DB and server?

    Hard

    Offline-first means the UI reads from the local database and never waits on the network to render.

    // 1) UI reads from Room — the Flow always carries the latest local state
    class TodoRepo(private val dao: TodoDao, private val api: TodoApi) {
      fun observe(): Flow<List<Todo>> = dao.observeAll().map { rows -> rows.map { it.toDomain() } }
    
      suspend fun add(title: String) {
        val local = TodoEntity(
    …
  6. 06

    How do you securely store secrets (tokens, keys) on Android?

    Medium

    The only real secure store on the device is the Android Keystore; everything else is a wrapper around it.

    // A hardware-backed AES key that never leaves the Keystore
    fun ensureKey(alias: String) {
      val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
      if (ks.containsAlias(alias)) return
      KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
        init(
    …
  7. 07

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

    Medium

    Room does not turn a JOIN into an object graph for you — you declare the shape with @Embedded plus @Relation, and Room runs one query per side and stitches the results in memory.

    @Entity(tableName = "author")
    data class AuthorEntity(@PrimaryKey val id: Long, val name: String)
    
    @Entity(
      tableName = "book",
      foreignKeys = [ForeignKey(
    …
  8. 08

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

    Medium

    A DAO is tested against a real database rather than a mock, because the whole value of Room is the SQL it generates and a mock exercises none of it.

    class NoteDaoTest {
      private lateinit var db: AppDatabase
      private lateinit var dao: NoteDao
    
      @Before fun setUp() {
        db = Room.inMemoryDatabaseBuilder(
    …
  9. 09

    A Room query that was instant at a thousand rows takes half a second at a hundred thousand. How do you find and fix it?

    Hard

    Start with EXPLAIN QUERY PLAN, because a scan where you assumed an index lookup is nearly always the answer.

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

    An insert of a row that already exists either silently does nothing or wipes its children. What do the OnConflictStrategy options actually do?

    Medium

    The conflict strategy decides what SQLite does when an insert violates a unique constraint, and the two that people reach for first fail in opposite, very quiet ways.

    @Entity(
      tableName = "contact",
      indices = [Index(value = ["remoteId"], unique = true)]
    )
    data class ContactEntity(
      @PrimaryKey(autoGenerate = true) val id: Long = 0,
    …
  11. 11

    The user wants an exported PDF saved where their file manager can find it. How do you write it without WRITE_EXTERNAL_STORAGE?

    Medium

    Under scoped storage an app reaches shared storage through MediaStore or the Storage Access Framework, and neither needs a storage permission for a file the app itself creates.

    // Let the user choose the destination — no permission declared at all
    val createDoc = registerForActivityResult(
      ActivityResultContracts.CreateDocument("application/pdf")
    ) { uri ->
      if (uri == null) return@registerForActivityResult
      lifecycleScope.launch(Dispatchers.IO) {
    …
  12. 12

    The user logs out. What exactly has to be cleared, and what breaks if you only clear the token?

    Easy

    Clearing the token merely stops new requests being authorised — every local copy of the previous user's data is still on the device, and the next person to sign in will see it.

    class SessionCleaner @Inject constructor(
      private val db: AppDatabase,
      private val tokens: TokenStore,
      private val settings: DataStore<Preferences>,
      private val imageLoader: ImageLoader,
      private val httpCache: Cache,
    …
  13. 13

    A teammate replaced every apply() with commit() after a setting went missing on a crash — what does each one actually do?

    Easy

    Both change the in-memory map immediately; apply() hands the disk write to a background thread and swallows any failure, while commit() writes on the calling thread and returns whether it worked.

    val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
    
    // apply(): memory now, disk later. "Later" is not free — the framework blocks
    // the main thread in QueuedWork.waitToFinish() on every lifecycle transition
    // until the queue drains, which is the ANR you find in the trace.
    prefs.edit { putString("theme", "dark") }
    …
  14. 14

    You insert a row and the list only shows it after leaving the screen and coming back — what is wrong with the DAO?

    Easy

    The query returns a plain List, which is a one-shot read: it answers once and never speaks again.

    @Dao
    interface TaskDao {
      // ❌ one shot: right once, then never again
      @Query("SELECT * FROM task ORDER BY dueAt")
      suspend fun getAll(): List<TaskEntity>
    …
  15. 15

    At launch the theme flashes the default and only then switches to the user's choice — how should a DataStore-backed setting be read?

    Medium

    DataStore has no synchronous getter by design, so the fix is to keep the UI undecided until the first value arrives rather than to block a thread waiting for it.

    // Top level of the file: one instance per file per process. A second
    // preferencesDataStore over "settings" throws IllegalStateException.
    val Context.settings: DataStore<Preferences> by preferencesDataStore(name = "settings")
    
    private val THEME = stringPreferencesKey("theme")
    …
  16. 16

    A JSON file the app writes itself sometimes comes back truncated, and sometimes it is gone entirely. Where should it live and how should it be written?

    Medium

    Truncated means the write was not atomic — a crash or a process kill landed between opening the file and finishing it — and gone entirely means it was in cacheDir, which the system empties whenever it likes.

    // ❌ opening the target truncates it first: a crash here loses the old copy too
    File(context.filesDir, "cart.json").writeText(json)
    
    // ✅ AtomicFile: writes a side file, syncs it, renames over the target
    private val cart = AtomicFile(File(context.filesDir, "cart.json"))
    …
  17. 17

    After an update users crash on launch with "Room cannot verify the data integrity" — what happened, and what do you ship next?

    Medium

    Room stores a hash of the schema it was compiled against in room_master_table and compares it on every open; that message means the file on disk was built by a different schema than the one in the APK.

    @Database(
      entities = [TaskEntity::class],
      version = 5,
      exportSchema = true,                    // and commit schemas/*.json
      autoMigrations = [AutoMigration(from = 4, to = 5, spec = AppDatabase.RenameDue::class)]
    )
    …
  18. 18

    During a sync your list Flow emits hundreds of times and the screen flickers, though only ten rows really changed — why, and what do you do?

    Medium

    Room's invalidation is per table and per transaction, never per row: each insert outside a transaction is its own transaction, and every one of them makes every Flow observing that table re-run its whole query.

    // ❌ 300 rows, 300 transactions, 300 invalidation signals, 300 re-queries
    suspend fun applyRemoteBad(rows: List<TaskEntity>) {
      rows.forEach { dao.upsert(it) }
    }
    
    // ✅ one commit, one notification, and nobody observes a half-applied sync
    …
  19. 19

    A repository method that writes inside db.withTransaction hangs forever — no crash, no exception, the coroutine never resumes. Where do you look?

    Hard

    Room runs a suspending transaction on one thread taken from its transaction executor and pins the block to it, so anything inside the block that waits on the database from another thread is waiting for a transaction that cannot finish until that call returns.

    // ❌ hangs forever: the DAO call leaves the transaction thread, then blocks
    // waiting for a database held by the transaction that is waiting for it
    suspend fun saveBad(order: Order) = db.withTransaction {
      withContext(Dispatchers.IO) { orderDao.insert(order.toEntity()) }
      lineDao.insertAll(order.lines.map { it.toEntity() })
    }
    …
  20. 20

    A user restores your app on a new phone and it crash-loops on launch while decrypting its own preferences — what went wrong?

    Hard

    Auto Backup restored the encrypted file but not the key: keys in the Android Keystore are hardware-bound and never leave the device, so the ciphertext lands on the new phone with nothing on it that can decrypt it.

    // Keystore-sealed bytes belong where backup and restore cannot reach them
    private val sealed = File(context.noBackupFilesDir, "session.bin")
    
    // ...and the read must still survive backups written before that fix shipped
    fun loadToken(): String? = try {
      crypto.decrypt(sealed.readBytes())
    …