Data Storage & Persistence
Apps that lose user data on the next launch feel broken. Apps that keep fetching from the network feel slow. The persistence layer is what makes an app feel fast and reliable — and Android offers the right tool for every shape of data.
Topic 1 · Local Storage
SharedPreferences vs DataStore
SharedPreferences is the legacy key-value store. It's synchronous, can
deadlock the main thread, doesn't handle errors well, and offers no type
safety. Use DataStore for all new code.
Legacy — avoid
- Synchronous reads block the main thread
- No error signalling — silent failures
- No transactional consistency guarantees
- No type safety — strings everywhere
- Apply vs commit confusion
Modern — recommended
- Asynchronous via Kotlin Flow
- Transactional updates with strong guarantees
- Type-safe with Proto DataStore
- Coroutine-friendly, cancellable
- Migration helper from SharedPreferences
Preferences DataStore (key-value)
// Top-level singleton — one DataStore per name per process
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore("settings")
object SettingsKeys {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val LAST_SYNC = longPreferencesKey("last_sync")
val AUTH_TOKEN = stringPreferencesKey("auth_token")
}
class SettingsRepository @Inject constructor(
@ApplicationContext private val ctx: Context
) {
val darkMode: Flow<Boolean> = ctx.settingsDataStore.data
.catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }
.map { it[SettingsKeys.DARK_MODE] ?: false }
suspend fun setDarkMode(enabled: Boolean) {
ctx.settingsDataStore.edit { prefs ->
prefs[SettingsKeys.DARK_MODE] = enabled
}
}
}
Proto DataStore (typed schema)
For complex settings, use Proto DataStore — you define a .proto schema and
get a generated Kotlin class with type-safe builders. No more stringly-typed
keys.
Room database
Room is an SQL database with a Kotlin-friendly API. It validates queries at compile time and generates the boilerplate you'd otherwise hand-write.
// 1. Entity — one row in a table
@Entity(tableName = "users",
indices = [Index(value = ["email"], unique = true)])
data class UserEntity(
@PrimaryKey val id: String,
val name: String,
val email: String,
val updatedAt: Long
)
// 2. DAO — query interface; Room generates the implementation
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :id")
fun observe(id: String): Flow<UserEntity?> // reactive — emits on change
@Query("SELECT * FROM users ORDER BY name ASC")
suspend fun list(): List<UserEntity> // suspend — one-shot
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(user: UserEntity)
@Delete
suspend fun delete(user: UserEntity)
@Transaction
suspend fun replaceAll(users: List<UserEntity>) {
clear()
upsertAll(users)
}
@Query("DELETE FROM users") suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(users: List<UserEntity>)
}
// 3. Database — wires entities and DAOs
@Database(
entities = [UserEntity::class],
version = 2,
exportSchema = true // commit /schemas to git for diffing
)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
// 4. Provide via Hilt
@Module @InstallIn(SingletonComponent::class)
object DbModule {
@Provides @Singleton
fun provideDb(@ApplicationContext ctx: Context): AppDatabase =
Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
@Provides fun provideUserDao(db: AppDatabase) = db.userDao()
}
Migrations
Every schema change requires a Migration. Room destroys data on version mismatch unless you provide one — never ship without migrations.
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE users ADD COLUMN avatar_url TEXT")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_users_updated ON users(updatedAt)")
}
}
Topic 2 · Advanced Storage
ContentProviders & file storage
ContentProviders are the official way to share data across apps — backing the Contacts, MediaStore, and Calendar systems. Inside your own app, prefer Room directly.
For files, the modern approach uses scoped storage (Android 10+):
| Where to write | Use case | API |
|---|---|---|
context.filesDir | Private app data, deleted on uninstall | File, FileOutputStream |
context.cacheDir | Disposable caches, OS may purge | File |
MediaStore | Public photos, videos, audio | MediaStore.Images.Media |
| Storage Access Framework | User-picked files (any location) | ActivityResultContracts.OpenDocument |
// Pick a document with the SAF — no permission needed
private val pickFile = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
contentResolver.takePersistableUriPermission(
it, Intent.FLAG_GRANT_READ_URI_PERMISSION
)
viewModel.onFilePicked(it)
}
}
Encrypted storage
Sensitive data (auth tokens, PII) should be encrypted at rest. The
androidx.security library provides EncryptedSharedPreferences and
EncryptedFile, both backed by AES-256 keys stored in the Android Keystore.
val masterKey = MasterKey.Builder(ctx)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val securePrefs = EncryptedSharedPreferences.create(
ctx, "auth_secure", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("refresh_token", token).apply()
Caching strategies
Common cache strategies you'll implement:
| Strategy | When to use |
|---|---|
| Cache-then-network | Show stale data instantly; refresh in background |
| Network-then-cache | Always fetch fresh; fall back to cache on failure |
| Stale-while-revalidate | Serve cache; revalidate; emit fresh data when ready |
| TTL-based | Cache valid for N seconds/minutes; refetch on expiry |
class ArticleRepository @Inject constructor(
private val api: ArticleApi,
private val dao: ArticleDao
) {
// Cache-then-network: emit cache immediately, refresh in background
fun observeArticles(): Flow<List<Article>> = dao.observeAll()
.map { it.map(ArticleEntity::toDomain) }
.onStart {
launchIn { runCatching { refresh() } }
}
private suspend fun refresh() {
val fresh = api.list()
dao.replaceAll(fresh.map(ArticleDto::toEntity))
}
}
Key takeaways
Practice exercises
- 01
Theme preference with DataStore
Persist a dark-mode toggle and observe it as Flow<Boolean>. Drive the Compose theme from it.
- 02
Notes app with Room
Build a NotesEntity + NotesDao + NotesRepository. Add a search query and order by updatedAt DESC.
- 03
Migration test
Add a new column in v2 of your schema. Write a MigrationTestHelper test asserting data preserves.
Next module
Continue to Module 06 — Networking & API Integration to fetch and post data with Retrofit, OkHttp, and Moshi.