Skip to main content
Module: 05 of 13Duration: 2 weeksTopics: 2 · 4 subtopicsPrerequisites: Modules 01–04

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.

SharedPreferences

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
DataStore

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 writeUse caseAPI
context.filesDirPrivate app data, deleted on uninstallFile, FileOutputStream
context.cacheDirDisposable caches, OS may purgeFile
MediaStorePublic photos, videos, audioMediaStore.Images.Media
Storage Access FrameworkUser-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:

StrategyWhen to use
Cache-then-networkShow stale data instantly; refresh in background
Network-then-cacheAlways fetch fresh; fall back to cache on failure
Stale-while-revalidateServe cache; revalidate; emit fresh data when ready
TTL-basedCache 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

  1. 01

    Theme preference with DataStore

    Persist a dark-mode toggle and observe it as Flow<Boolean>. Drive the Compose theme from it.

  2. 02

    Notes app with Room

    Build a NotesEntity + NotesDao + NotesRepository. Add a search query and order by updatedAt DESC.

  3. 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.