🤖 Unit 10 • Android Storage & Persistence Architecture

Data Storage: DataStore & Room Database

The authoritative engineering masterclass on Android persistence: Replacing legacy SharedPreferences with transactional, asynchronous Jetpack Preferences DataStore (storing user settings, theme preferences, and credentials), and implementing production relational database architecture with the Room Persistence Library (@Entity tables, @Dao data access objects, @Database engine, reactive Flow queries, relational 1-to-Many associations, and automated UI list synchronization).

Curriculum: Mobile Software Engineering (Android Track)
Unit: 10 of 15
Prerequisite: Unit 02 (Kotlin), Unit 09 (Android Architecture)
Libraries: Room 2.6+ / DataStore 1.1+ (SQLite SQLiteOpenHelper)
01

The Android Storage Landscape: Key-Value vs. Relational Data

💾 Selecting the Correct Persistence Mechanism

Android provides multiple distinct storage paradigms depending on data volume, query complexity, and structure:

  • Jetpack Preferences DataStore: Engineered for lightweight, unstructured Key-Value data (e.g. user authentication tokens, theme toggles, notification flags, and last-sync timestamps). Backed by asynchronous Kotlin Coroutines and Kotlin Flow.
  • Room Database (SQLite): Engineered for complex, structured, tabular relational data with hundreds or thousands of records (e.g. offline course catalogs, student enrollment histories, cached transactions). Provides full SQL querying, indexing, foreign keys, and reactive observable streams.
02

Jetpack Preferences DataStore vs. Legacy SharedPreferences

⚠️ The Legacy SharedPreferences Pitfalls

For over a decade, Android relied on SharedPreferences. However, it suffers from critical architectural flaws:

  1. UI Thread Blocking (ANR): Calling commit() executes synchronous disk I/O on the Main thread, inducing Application Not Responding (ANR) crashes. While apply() queues writes asynchronously, it still blocks the main thread during Activity teardowns.
  2. Runtime ClassCastException: Keys are not type-safe. Storing an Int under key "user_id" and reading it as a String throws a fatal crash at runtime.
  3. No Transactional Safety: Lack of atomic transaction guarantees can leave XML preference files corrupted upon unexpected app termination.
  4. Lack of Observable Streams: Polling listeners are error-prone and leak memory if not unregistered.

Jetpack DataStore completely replaces SharedPreferences by providing asynchronous, transactional, and type-safe storage built natively on Kotlin Coroutines and Flow.

Architectural Attribute Legacy SharedPreferences Jetpack Preferences DataStore
Threading Model Synchronous disk writes blocking Main UI thread 100% Asynchronous execution on Dispatchers.IO
Observable Streams Fragile string-keyed listeners (prone to memory leaks) Native reactive Flow<Preferences>
Error Handling Silently drops write errors; throws ClassCastException Encapsulated in standard Kotlin IOException handlers
Transactional Safety Non-atomic; file corruption risk on crash ACID transactional updates via atomic temporary files
Flowchart 1: Jetpack Preferences DataStore Transactional Engine
context.dataStore.edit { } Suspend Mutation Lambda
→
Dispatchers.IO Thread Background Disk Worker
→
Atomic File Write Writes to temporary .tmp file
↓ On Atomic Rename Commit
Disk Cache Synchronized app_settings.preferences_pb
→
dataStore.data Flow Emits Emits updated Preferences Object
→
UI Updates Reactively Theme / Settings Swap
SettingsDataStore.kt — Asynchronous Preferences Repository
package com.devascent.storage.datastore

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException

// Top-level delegate: creates singleton DataStore instance
private val Context.userPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "user_settings")

class SettingsRepository(private val context: Context) {

    // Strongly-typed preference keys
    private object PreferencesKeys {
        val DARK_MODE_ENABLED = booleanPreferencesKey("dark_mode_enabled")
        val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
        val STUDENT_HANDLE = stringPreferencesKey("student_handle")
    }

    // 1. Reactive Flow Reading with Error Encapsulation
    val isDarkModeFlow: Flow<Boolean> = context.userPreferencesDataStore.data
        .catch { exception ->
            if (exception is IOException) {
                emit(emptyPreferences()) // Graceful fallback on disk read error
            } else {
                throw exception
            }
        }
        .map { preferences ->
            preferences[PreferencesKeys.DARK_MODE_ENABLED] ?: false
        }

    // 2. Transactional Asynchronous Mutation
    suspend fun setDarkMode(enabled: Boolean) {
        context.userPreferencesDataStore.edit { preferences ->
            preferences[PreferencesKeys.DARK_MODE_ENABLED] = enabled
        }
    }
}
03

The Room Persistence Library: The 3 Core Pillars

🏛️ Compile-Time Verified SQLite ORM

Raw SQLite programming using SQLiteOpenHelper required writing raw string queries, manually iterating over Cursor indices, and converting columns into Java objects. Syntax errors inside SQL strings caused runtime app crashes!

The Room Persistence Library provides an abstraction layer over SQLite, verifying all SQL syntax at compile-time and eliminating boilerplate cursor mapping through three fundamental pillars:

  • 1. @Entity (The Table Schema): Standard Kotlin data classes annotated to represent database tables. Declares column names, types, primary keys, and indices.
  • 2. @Dao (Data Access Object): The interface defining methods for querying, inserting, updating, and deleting data. Validates SQL queries during compilation.
  • 3. @Database (The Main Connection Hub): An abstract class extending RoomDatabase. Manages SQLite database instantiation, versioning, migrations, and DAO exposure.
Flowchart 2: The 3 Pillars of Room Database Architecture
@Entity (CourseEntity) Table: "courses" Schema
↔
@Dao (CourseDao) SQL Queries & CRUD Methods
↔
@Database (AppDatabase) Room.databaseBuilder() Singleton
↓ Underlying SQLite Engine
Room InvalidationTracker Monitors Table Writes
↔
SQLite Database Driver /data/data/com.devascent/databases/devascent.db
04

CRUD Operations & Reactive Flow Observation

⚡ Zero-Polling Reactive Flow Queries

The most powerful capability of Room is its native integration with Kotlin Coroutine Flow:

  • When a DAO query returns a Flow<List<CourseEntity>>, Room automatically registers internal SQLite triggers via its InvalidationTracker.
  • Whenever an @Insert, @Update, or @Delete transaction modifies the underlying table, Room re-executes the query on a background thread and emits the fresh list to the Flow.
  • The UI layer never needs to manually call "refresh" or poll the database—the screen updates reactively and instantaneously!
Flowchart 3: Room Reactive Query Invalidation Pipeline
courseDao.insertCourse(newCourse) Database Write Transaction
→
SQLite Triggers Fired InvalidationTracker notified
→
Query Re-executed on IO SELECT * FROM courses
↓
Flow<List<Course>> Emits Fresh dataset dispatched
→
ViewModel StateFlow Updated UiState.Success(courses)
→
LazyColumn Recomposes New row animates onto screen
RoomDatabaseCore.kt — Entities, DAO & Database Builder
package com.devascent.storage.room

import android.content.Context
import androidx.room.*
import kotlinx.coroutines.flow.Flow

// ==================== 1. ENTITY DEFINITION ====================
@Entity(tableName = "courses")
data class CourseEntity(
    @PrimaryKey
    val id: String,
    
    @ColumnInfo(name = "course_title")
    val title: String,
    
    @ColumnInfo(name = "module_count")
    val moduleCount: Int,
    
    @ColumnInfo(name = "is_enrolled")
    val isEnrolled: Boolean = false
)

// ==================== 2. DATA ACCESS OBJECT (DAO) ====================
@Dao
interface CourseDao {

    // Reactive Continuous Query: Emits automatically on table writes!
    @Query("SELECT * FROM courses ORDER BY course_title ASC")
    fun getAllCourses(): Flow<List<CourseEntity>>

    @Query("SELECT * FROM courses WHERE id = :courseId LIMIT 1")
    suspend fun getCourseById(courseId: String): CourseEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertCourse(course: CourseEntity)

    @Delete
    suspend fun deleteCourse(course: CourseEntity)

    @Query("UPDATE courses SET is_enrolled = :enrolled WHERE id = :courseId")
    suspend fun updateEnrollmentStatus(courseId: String, enrolled: Boolean)
}

// ==================== 3. DATABASE ENGINE ====================
@Database(entities = [CourseEntity::class], version = 1, exportSchema = false)
abstract class DevAscentDatabase : RoomDatabase() {
    abstract fun courseDao(): CourseDao

    companion object {
        @Volatile
        private var INSTANCE: DevAscentDatabase? = null

        fun getDatabase(context: Context): DevAscentDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    DevAscentDatabase::class.java,
                    "devascent_academy.db"
                )
                .fallbackToDestructiveMigration()
                .build()
                INSTANCE = instance
                instance
            }
        }
    }
}
05

Relational Table Modeling: 1-to-Many Relationships

🔗 1-to-Many Associations via @Relation

In relational databases, real-world data is normalized across multiple tables. For example, a single Course has multiple child Lessons:

  • Foreign Keys: The child table declares foreignKeys = [ForeignKey(entity = CourseEntity::class, parentColumns = ["id"], childColumns = ["course_id"], onDelete = ForeignKey.CASCADE)]. If a course is deleted, all its associated lessons are automatically purged.
  • @Embedded & @Relation: Room avoids complex SQL JOIN boilerplate by allowing developers to model composite query results into Kotlin relational containers.
CourseWithLessons.kt — Relational Data Modeling
@Entity(
    tableName = "lessons",
    foreignKeys = [
        ForeignKey(
            entity = CourseEntity::class,
            parentColumns = ["id"],
            childColumns = ["course_id"],
            onDelete = ForeignKey.CASCADE
        )
    ],
    indices = [Index("course_id")]
)
data class LessonEntity(
    @PrimaryKey val lessonId: String,
    @ColumnInfo(name = "course_id") val courseId: String,
    val lessonTitle: String
)

// Relational Query Container: Combines parent Course with all child Lessons
data class CourseWithLessons(
    @Embedded val course: CourseEntity,
    
    @Relation(
        parentColumn = "id",
        entityColumn = "course_id"
    )
    val lessons: List<LessonEntity>
)
06

Production Capstone: DevAscent Student Persistence Suite

🚀 Complete Offline-First Architecture in Action

The complete implementation below showcases the fusion of Preferences DataStore (for instant dark mode preference persistence) and Room Database (for continuous, reactive course enrollment tracking with Flow).

StorageSuiteApp.kt — DataStore & Room Integration
package com.devascent.storage.app

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.devascent.storage.datastore.SettingsRepository
import com.devascent.storage.room.CourseDao
import com.devascent.storage.room.CourseEntity
import com.devascent.storage.room.DevAscentDatabase
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

// ViewModel Managing both Room & DataStore
class StorageSuiteViewModel(
    private val courseDao: CourseDao,
    private val settingsRepo: SettingsRepository
) : ViewModel() {

    // Continuous Reactive Flow from Room DB
    val courses: StateFlow<List<CourseEntity>> = courseDao.getAllCourses()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    // Continuous Flow from DataStore
    val isDarkMode: StateFlow<Boolean> = settingsRepo.isDarkModeFlow
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)

    fun toggleTheme(dark: Boolean) {
        viewModelScope.launch {
            settingsRepo.setDarkMode(dark)
            Log.i("StorageApp", "DataStore: Persisted dark mode preference = $dark")
        }
    }

    fun addSampleCourse(id: String, title: String, modules: Int) {
        viewModelScope.launch {
            courseDao.upsertCourse(CourseEntity(id, title, modules, true))
            Log.i("StorageApp", "Room: Inserted course $title")
        }
    }

    fun deleteCourse(course: CourseEntity) {
        viewModelScope.launch {
            courseDao.deleteCourse(course)
            Log.w("StorageApp", "Room: Deleted course ${course.title}")
        }
    }
}

class StorageActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val db = DevAscentDatabase.getDatabase(applicationContext)
        val settings = SettingsRepository(applicationContext)

        setContent {
            val vm: StorageSuiteViewModel = viewModel(
                factory = object : ViewModelProvider.Factory {
                    @Suppress("UNCHECKED_CAST")
                    override fun <T : ViewModel> create(modelClass: Class<T>): T =
                        StorageSuiteViewModel(db.courseDao(), settings) as T
                }
            )

            val isDark by vm.isDarkMode.collectAsStateWithLifecycle()
            val courseList by vm.courses.collectAsStateWithLifecycle()

            MaterialTheme(
                colorScheme = if (isDark) darkColorScheme() else lightColorScheme()
            ) {
                StorageAppScreen(
                    isDark = isDark,
                    courses = courseList,
                    onToggleTheme = { vm.toggleTheme(it) },
                    onAdd = { vm.addSampleCourse("unit10", "Unit 10: Data Storage", 15) },
                    onDelete = { vm.deleteCourse(it) }
                )
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StorageAppScreen(
    isDark: Boolean,
    courses: List<CourseEntity>,
    onToggleTheme: (Boolean) -> Unit,
    onAdd: () -> Unit,
    onDelete: (CourseEntity) -> Unit
) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("DevAscent Storage Lab") },
                actions = {
                    Text("Dark Mode", modifier = Modifier.align(Alignment.CenterVertically))
                    Switch(checked = isDark, onCheckedChange = onToggleTheme)
                }
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = onAdd) {
                Icon(Icons.Default.Add, contentDescription = "Add Course")
            }
        }
    ) { padding ->
        LazyColumn(
            modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(10.dp)
        ) {
            items(courses, key = { it.id }) { course ->
                Card(modifier = Modifier.fillMaxWidth()) {
                    Row(
                        modifier = Modifier.padding(16.dp).fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Column {
                            Text(text = course.title, style = MaterialTheme.typography.titleMedium)
                            Text(text = "${course.moduleCount} Modules", style = MaterialTheme.typography.bodySmall)
                        }
                        IconButton(onClick = { onDelete(course) }) {
                            Icon(Icons.Default.Delete, contentDescription = "Delete")
                        }
                    }
                }
            }
        }
    }
}
Runtime Logcat & Database Transaction Stream
I/StorageApp: DevAscentDatabase opened: devascent_academy.db on Dispatchers.IO D/StorageApp: Room SQLite InvalidationTracker initialized: triggers registered for table 'courses' I/StorageApp: SettingsRepository: DataStore initialized (user_settings.preferences_pb) D/StorageApp: DataStore Flow emitted: isDarkMode = false -> Applied Light MaterialTheme D/StorageApp: User toggled Dark Mode switch -> onToggleTheme(true) I/StorageApp: DataStore: Persisted dark mode preference = true (Atomic file write on IO) D/StorageApp: UI recomposed: Dark MaterialTheme active with 0 jank frames D/StorageApp: User clicked FAB (+) -> onAdd() executed I/StorageApp: Room: Inserted course Unit 10: Data Storage (id=unit10) I/StorageApp: InvalidationTracker fired -> re-queried courses -> Flow emitted 1 record D/StorageApp: LazyColumn rendered: Unit 10 item animated into view automatically
07

University Exam & Technical Interview Viva Preparation Guide

Q1. Why has Jetpack Preferences DataStore replaced SharedPreferences in modern Android?
SharedPreferences blocks the Main UI thread during commit() disk I/O, which frequently triggers Application Not Responding (ANR) dialogs. Furthermore, it lacks type safety (throwing runtime ClassCastException), lacks transactional consistency (corrupting files if killed mid-write), and does not expose a native reactive stream. Preferences DataStore runs 100% asynchronously on Dispatchers.IO via Kotlin Coroutines, guarantees ACID transactional writes via atomic temporary file renaming, and exposes continuous reactive Flow<Preferences> streams.
Q2. What are the three fundamental components of the Room Persistence Library?
The three core pillars are:
  1. @Entity: Kotlin data class representing a table in the SQLite database. Configures primary keys, table names, and column annotations.
  2. @Dao (Data Access Object): Clean interface providing SQL query contracts (@Insert, @Query, @Update, @Delete) verified at compile time.
  3. @Database: Abstract class extending RoomDatabase serving as the central connection access point, defining table schemas and versioning.
Q3. How does Room verify queries at compile time?
During build compilation, the Room Annotation Processor (KSP / KAPT) parses all SQL statements declared inside @Query("...") annotations against the declared @Entity schema tables and column definitions. If you misspell a column name, query a nonexistent table, or return an incompatible data type, compilation fails immediately with an explicit error message rather than crashing at runtime on a user's device.
Q4. How does Room achieve zero-polling reactive updates with Kotlin Flow?
When a DAO method returns a Flow<T>, Room attaches an internal InvalidationTracker equipped with SQLite database triggers on the queried tables. When any write transaction (@Insert, @Update, @Delete) commits to those tables, the tracker detects the modification, re-executes the query asynchronously on a background dispatcher, and pushes the updated dataset down the Flow to active collectors automatically.
Q5. Why must Room Database instances be strict Singletons?
Instantiating a RoomDatabase is an extremely resource-intensive operation that creates heavy SQLite file locks, connection pools, and in-memory query caches. Creating multiple database instances simultaneously can induce severe file lock contention, race conditions, memory leaks, and out-of-sync caching. A singleton pattern (using @Volatile and double-checked locking) guarantees only one connection pool exists throughout the process lifespan.
Q6. What is the difference between OnConflictStrategy.REPLACE and ABORT in Room?
When inserting a record whose primary key already exists:
  • REPLACE: Deletes the existing conflicting row and inserts the new row (acting as an Upsert), updating all fields atomically.
  • ABORT (Default): Cancels and rolls back the current SQLite transaction, throwing an SQLiteConstraintException.
Q7. What is the purpose of Foreign Keys with ForeignKey.CASCADE in Room?
Declaring foreign keys establishes relational integrity between parent and child tables (e.g., Course and Lessons). Setting onDelete = ForeignKey.CASCADE guarantees that if a parent record is deleted from the courses table, SQLite automatically deletes all associated child records in the lessons table, preventing orphaned records from polluting database storage.
Q8. What happens if you increment the Room Database version without providing a Migration?
If you increment version = 2 without supplying a corresponding Migration object, Room throws an IllegalStateException: A migration from 1 to 2 was required but not found upon startup, crashing the application. If fallbackToDestructiveMigration() is enabled, Room will drop all preexisting tables and recreate them empty, causing permanent user data loss. In enterprise apps, explicit Migration(1, 2) { database.execSQL("ALTER TABLE ...") } rules are mandatory.