Android Persistence
Room, DataStore, SharedPreferences, EncryptedSharedPreferences, file storage
- 01
What persistence options are available on Android and when do you choose each?
EasyPick 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 } } … - 02
What is Room? Show a basic Database, DAO, and migration.
MediumRoom 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 … - 03
What are TypeConverters in Room? When are they needed?
MediumRoom 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) } … - 04
Preferences DataStore vs Proto DataStore — what's the difference?
MediumBoth 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 } … - 05
How do you do offline-first sync between local DB and server?
HardOffline-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( … - 06
How do you securely store secrets (tokens, keys) on Android?
MediumThe 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( … - 07
How do you model one-to-many and many-to-many relations in Room, and why does such a query need
@Transaction?MediumRoom does not turn a JOIN into an object graph for you — you declare the shape with
@Embeddedplus@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( … - 08
How do you test a Room DAO, and how do you test a migration?
MediumA 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( … - 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?
HardStart 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
An insert of a row that already exists either silently does nothing or wipes its children. What do the
OnConflictStrategyoptions actually do?MediumThe 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
The user wants an exported PDF saved where their file manager can find it. How do you write it without
WRITE_EXTERNAL_STORAGE?MediumUnder 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
The user logs out. What exactly has to be cleared, and what breaks if you only clear the token?
EasyClearing 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
A teammate replaced every
apply()withcommit()after a setting went missing on a crash — what does each one actually do?EasyBoth change the in-memory map immediately;
apply()hands the disk write to a background thread and swallows any failure, whilecommit()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
You insert a row and the list only shows it after leaving the screen and coming back — what is wrong with the DAO?
EasyThe 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
At launch the theme flashes the default and only then switches to the user's choice — how should a DataStore-backed setting be read?
MediumDataStore 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
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?
MediumTruncated 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
After an update users crash on launch with "Room cannot verify the data integrity" — what happened, and what do you ship next?
MediumRoom stores a hash of the schema it was compiled against in
room_master_tableand 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
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?
MediumRoom'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
A repository method that writes inside
db.withTransactionhangs forever — no crash, no exception, the coroutine never resumes. Where do you look?HardRoom 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
A user restores your app on a new phone and it crash-loops on launch while decrypting its own preferences — what went wrong?
HardAuto 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()) …