Open menu with table of contents Storage Options & App Architecture
Logo of Stuttgart Media University for light theme Logo of Stuttgart Media University for dark theme
Android Development

Storage Options & App Architecture

Stuttgart Media University

1 Agenda

  • Shared Preferences and Jetpack DataStore
  • Internal and External Storage (Scoped Storage)
  • SQLite Databases
  • Room
  • App Architecture: MVVM, Repository, Dependency Injection
  • Alternative Databases and Cloud Storage
  • Assignment 4

2 Shared Preferences

  • Simple way to persistently store small values in a key-value file (XML in the app's private directory)
  • Useful for user preferences, app settings, flags like "onboarding shown"
  • Access via the class SharedPreferences
    • context.getSharedPreferences(name, Context.MODE_PRIVATE): one file per name
    • Read with getBoolean(), getString(), getInt(), ... the second parameter is the default value
  • Write through a SharedPreferences.Editor obtained with edit()
    • putBoolean(), putString(), ... then apply() (asynchronous, preferred) or commit() (blocking, returns success)
    • The androidx.core KTX extension edit { } does this for you
  • Not for: large data, lists of objects, anything that needs queries

3 Shared Preferences Example

val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)

// read, the second parameter is the default
val silentMode = prefs.getBoolean("silentMode", false)
val lastSearch = prefs.getString("lastSearch", "") ?: ""

// write with the androidx.core KTX extension: opens an editor and calls apply()
prefs.edit {
    putBoolean("silentMode", true)
    putString("lastSearch", "Matrix")
}
  • apply() writes to memory immediately and to disk asynchronously, so the UI is not blocked
  • Reads are synchronous and may block the first time the file is loaded; keep the files small

4 Jetpack DataStore

The modern replacement for Shared Preferences, built on coroutines and Flow. No blocking calls, no apply() / commit(), no lost writes.

// one instance per process, defined at the top level of a file
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

val SILENT_MODE = booleanPreferencesKey("silent_mode")

// reading: a Flow that emits the current value and every change
fun silentMode(context: Context): Flow<Boolean> =
    context.dataStore.data.map { prefs -> prefs[SILENT_MODE] ?: false }

// writing: a suspend function, call it from viewModelScope.launch { }
suspend fun setSilentMode(context: Context, enabled: Boolean) {
    context.dataStore.edit { prefs -> prefs[SILENT_MODE] = enabled }
}
  • Two flavours: Preferences DataStore (keys, as above) and Proto DataStore (typed schema)
  • Catalog: androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version = "1.2.1" }
  • Guide: DataStore

5 Internal Storage

  • Files saved to the internal storage are private to the app, other apps cannot read them
  • They are removed when the app is uninstalled, no permission is needed
  • context.filesDir: persistent files (/data/data/<package>/files)
  • context.cacheDir: files the system may delete when storage runs low; clean up yourself too
  • Write with openFileOutput(name, mode), returns a FileOutputStream
    • Context.MODE_PRIVATE creates or replaces the file
    • Context.MODE_APPEND appends to an existing file
  • Read with openFileInput(name), returns a FileInputStream
  • Static files shipped with the app live in res/raw/ and are opened with resources.openRawResource(R.raw.filename)

6 Internal Storage Example

// write: use { } closes the stream, also when an exception is thrown
context.openFileOutput("notes.txt", Context.MODE_PRIVATE).use { stream ->
    stream.write("hello world!".toByteArray())
}

// read
val text = context.openFileInput("notes.txt").bufferedReader().use { it.readText() }

// or with java.io.File
val file = File(context.filesDir, "notes.txt")
file.writeText("hello world!")
  • File I/O blocks: call it from a coroutine on Dispatchers.IO (see lecture 10), never on the main thread
  • The File API also works for context.cacheDir and the external app-specific directories

7 External Storage and Scoped Storage

  • "External storage" is the shared part of the device storage (historically the SD card): photos, downloads, music. It is visible to the user and to other apps
  • Since Android 10 (API 29) apps run with scoped storage: an app sees only its own app-specific directory and the media it created itself. Android 11 (API 30) enforces it
  • WRITE_EXTERNAL_STORAGE has no effect for apps targeting API 30+, READ_EXTERNAL_STORAGE was replaced by READ_MEDIA_IMAGES / READ_MEDIA_VIDEO / READ_MEDIA_AUDIO on Android 13 (API 33)
  • Broad file system access is gone. Use the API that fits the data:
Data API Permission
Files only your app uses getExternalFilesDir(), externalCacheDir none
Photos, videos, audio your app creates MediaStore (ContentResolver.insert()) none
Let the user pick a photo or video Photo Picker (PickVisualMedia) none
Let the user pick any document Storage Access Framework (OpenDocument) none
Read all media of the user MediaStore + READ_MEDIA_* runtime permission yes

8 App-Specific External Storage

// Checks if the external storage is mounted and writable
fun isExternalStorageWritable(): Boolean =
    Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED

// Android/data/<package>/files/Pictures on the external storage
val picturesDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
val photo = File(picturesDir, "poster.jpg")
  • getExternalFilesDir(type): type is a media directory like DIRECTORY_PICTURES or DIRECTORY_MOVIES, null for the root of the app directory
  • No permission needed since Android 4.4 (API 19), the files are deleted when the app is uninstalled
  • Larger than internal storage, but may be unavailable (unmounted) on devices with removable storage
  • The MediaStore does not index these files, other apps and the gallery do not see them
  • Guide: App-specific storage

9 Shared Media: Photo Picker

The Photo Picker lets the user choose photos or videos without any permission. Available on Android 11+ and backported through Google Play services down to Android 4.4.

@Composable
fun PickImageButton(onImagePicked: (Uri) -> Unit) {
    val pickMedia = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia()
    ) { uri -> uri?.let(onImagePicked) }

    Button(onClick = {
        pickMedia.launch(
            PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
        )
    }) {
        Text(text = "Pick a photo")
    }
}
  • The result is a content:// Uri; load it with Coil (AsyncImage(model = uri, ...)) or open it with context.contentResolver.openInputStream(uri)
  • PickMultipleVisualMedia(maxItems = 5) for several items
  • Saving your own photos: insert them through MediaStore.Images.Media so the gallery sees them

10 Shared Documents: Storage Access Framework

val context = LocalContext.current
val openDocument = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
    uri?.let { context.contentResolver.openInputStream(it)?.use { stream -> /* read */ } }
}

// a system file picker filtered by MIME type
Button(onClick = { openDocument.launch(arrayOf("application/pdf", "text/plain")) }) {
    Text(text = "Open document")
}
  • OpenDocument, CreateDocument(mimeType), OpenDocumentTree (a whole folder)
  • Access is granted per Uri; call takePersistableUriPermission() to keep it after a restart
  • This is what the project category "Media and Camera" is about: camera via TakePicture, pictures via Photo Picker or MediaStore, files via the Storage Access Framework
  • Guides: Shared storage: media, documents

11 SQLite Databases

  • Android ships SQLite, a relational database in a single file in the app's private directory
  • The classic API: subclass SQLiteOpenHelper and override onCreate() (execute CREATE TABLE statements) and onUpgrade() (schema changes between versions)
  • This is what Room builds on. You rarely write it by hand today, but the concepts (schema version, upgrades, cursors) stay the same
class MovieOpenHelper(context: Context) :
    SQLiteOpenHelper(context, "movie.db", null, 1) {

    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL("CREATE TABLE movie (id INTEGER PRIMARY KEY, title TEXT, year TEXT)")
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        db.execSQL("DROP TABLE IF EXISTS movie")
        onCreate(db)
    }
}

12 SQLite Databases

  • Get a database with writableDatabase or readableDatabase on the helper; both return a SQLiteDatabase
  • insert(), update(), delete() take a ContentValues map, query() and rawQuery() return a Cursor pointing to the result rows
  • Everything is untyped: column names as strings, cursor.getString(cursor.getColumnIndexOrThrow("title"))
  • Errors (typos in SQL, wrong column types) show up at runtime, not at compile time
  • All calls block the calling thread: never on the main thread
  • The SDK includes the sqlite3 command line tool in platform-tools/, Android Studio has the Database Inspector (see Room)
val db = MovieOpenHelper(context).writableDatabase
db.insert("movie", null, ContentValues().apply {
    put("title", "Goldfinger")
    put("year", "1964")
})

13 Room

  • Room is Jetpack's persistence library: an abstraction layer over SQLite
  • You write annotated Kotlin: entities, a DAO interface, an abstract database class. Room generates the SQL and the implementation at compile time (KSP)
  • SQL queries are verified at compile time: a typo in a column name is a build error
  • Returns Flow and supports suspend functions out of the box: the main thread is never blocked
  • Schema versioning and migrations are built in
  • Typical use: local cache of remote data for offline use, or the app's own data (assignment 4)

14 Room Gradle Setup

gradle/libs.versions.toml:

[versions]
ksp = "2.3.12"
room = "2.8.5"

[libraries]
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

Project build.gradle.kts and module app/build.gradle.kts:

plugins {                                   // project level
    alias(libs.plugins.ksp) apply false
}

plugins {                                   // app module
    alias(libs.plugins.ksp)
}
dependencies {
    implementation(libs.androidx.room.runtime)   // Flow and suspend support included
    ksp(libs.androidx.room.compiler)             // generates the DAO and database code
}

KSP (Kotlin Symbol Processing) runs the Room compiler. The old kapt / annotationProcessor and the separate room-ktx artifact are no longer needed.

15 Entity

  • An entity is a Kotlin class that defines a table, every instance is one row
  • Annotated with @Entity, the class name is the table name (tableName = ... to change it)
  • Must have a primary key: @PrimaryKey(autoGenerate = true) lets SQLite assign increasing ids
  • Properties become columns, @ColumnInfo(name = ...) renames, @Ignore excludes a property
  • Supported types: primitives, String, ByteArray; everything else needs a TypeConverter
  • The same class can be the Gson target of a REST API (assignment 5), so network and database share one model
// model/Movie.kt (assignment 4)
@Entity
data class Movie(
    // Room generates the id when the movie is inserted (0 means "not set yet")
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val title: String,
    val year: String,
    val actor: String
)

16 Data Access Object (DAO)

  • An interface (or abstract class) annotated with @Dao, Room generates the implementation
  • @Insert, @Update, @Delete generate the SQL from the entity; @Query takes your own SQL
  • Write operations are suspend functions: Room runs them on its own background executor
  • A @Query returning Flow<List<Movie>> is observable: a new list is emitted after every change to the table
  • Easy to replace by a fake in tests, because the rest of the app only sees the interface
// model/MovieDao.kt (assignment 4)
@Dao
interface MovieDao {

    // Returning a Flow makes Room emit a new list every time the table changes
    @Query("SELECT * FROM movie ORDER BY id")
    fun getAllMovies(): Flow<List<Movie>>

    @Insert
    suspend fun insert(movie: Movie)

    @Delete
    suspend fun delete(movie: Movie)
}

17 Aside: Conflicts and Unique Indices

In assignment 5 a movie must not be saved twice. The entity gets a unique index on the API id and the insert ignores duplicates instead of throwing:

// model/Movie.kt (assignment 5)
@Entity(indices = [Index(value = ["imdbId"], unique = true)])
data class Movie(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    @SerializedName("imdbID") val imdbId: String,
    @SerializedName("Title") val title: String,
    @SerializedName("Year") val year: String,
    @SerializedName("Poster") val poster: String
)

// model/MovieDao.kt (assignment 5)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(movie: Movie)
  • OnConflictStrategy.IGNORE keeps the existing row, REPLACE overwrites it, ABORT (default) throws a SQLiteConstraintException
  • @Delete and @Update match rows by primary key, @Query("DELETE FROM movie WHERE imdbId = :id") for anything else

18 Database

  • An abstract class extending RoomDatabase, annotated with @Database(entities, version)
  • Declares one abstract function per DAO, Room generates the body
  • Creating an instance is expensive: hold exactly one per app (singleton)
  • Queries on the main thread throw by default; allowMainThreadQueries() exists but is never the answer
// model/MovieDatabase.kt (assignment 4)
@Database(entities = [Movie::class], version = 1, exportSchema = false)
abstract class MovieDatabase : RoomDatabase() {

    abstract fun movieDao(): MovieDao

    companion object {
        const val DATABASE_NAME = "movie_database"

        @Volatile
        private var instance: MovieDatabase? = null

        /** Returns the single database instance of the app and creates it on first access. */
        fun getDatabase(context: Context): MovieDatabase {
            return instance ?: synchronized(this) {
                Room.databaseBuilder(context, MovieDatabase::class.java, DATABASE_NAME)
                    .build()
                    .also { instance = it }
            }
        }
    }
}

19 Schema Changes and Migrations

  • Every schema change (new column, new table, changed type) needs a higher version; otherwise Room throws an IllegalStateException on start because the stored schema does not match
  • Development: fallbackToDestructiveMigration(dropAllTables = true) drops all data and recreates the tables when the version changes. This is what assignment 5 does (version = 2)
  • Production: a Migration object with the SQL that converts the old schema, added with addMigrations()
  • exportSchema = true writes the schema as JSON into the project, the basis for @AutoMigration
Room.databaseBuilder(context, MovieDatabase::class.java, DATABASE_NAME)
    .fallbackToDestructiveMigration(dropAllTables = true)    // data loss is acceptable here
    .build()

// keeps the data instead
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE movie ADD COLUMN poster TEXT NOT NULL DEFAULT ''")
    }
}
// .addMigrations(MIGRATION_1_2)

Guide: Migrating Room databases

20 Database Inspector

  • Android Studio > App Inspection > Database Inspector (app must be running, API 26+)
  • Shows all tables of all Room / SQLite databases of the app and their content, live
  • Run your own SQL queries, edit cells, export the database
  • "Live updates" re-runs the view whenever the app changes the table: perfect to see what insert() and delete() did
  • The database file itself lives in /data/data/de.hdmstuttgart.movietracker/databases/movie_database (context.getDatabasePath(name), the assignment 4 test checks that it exists)
  • Guide: Debug your database with the Database Inspector

21 App Architecture: Why?

Until assignment 3 the screens held the data in remember { mutableStateListOf() }. That breaks down quickly:

  • Configuration changes: a rotation recreates the Activity and the composition, non-saved state is gone
  • Separation of concerns: a composable that fetches data, stores it and renders it cannot be reasoned about or reused
  • Testability: business logic inside @Composable functions needs an emulator to be tested
  • Shared state: two screens (home, search) must see the same saved movies
  • Lifecycle: background work must survive the screen but not the app

Google's guide to app architecture answers this with layers and unidirectional data flow. Assignment 4 applies it to the MovieTracker app.

22 The Layers of the MovieTracker App

UI layer          HomeScreen / SearchScreen          @Composable, renders state, forwards events
                      ↓ state (StateFlow)   ↑ events (function calls)
                  HomeViewModel / SearchViewModel     holds UI state, survives rotation
                      ↓                     ↑
Data layer        MovieRepository                     single source of truth, interface
                      ↓                     ↑
                  MovieDao (Room)   MovieApi (Retrofit, assignment 5)
  • State flows down: Room emits a Flow<List<Movie>>, the repository passes it on, the ViewModel turns it into StateFlow<HomeUiState>, the screen collects it and recomposes
  • Events flow up: a click in HomeScreen calls viewModel.deleteMovie(movie), the ViewModel calls the repository, the repository the DAO, Room emits the new list and the circle closes
  • Each layer only knows the layer directly below it. The screen does not know Room exists
  • This pattern is called MVVM (Model - View - ViewModel)

23 ViewModel

  • androidx.lifecycle.ViewModel: a state holder scoped to a screen (Activity, Fragment or navigation back stack entry)
  • Survives configuration changes: a rotation recreates the Activity and the composition, the ViewModel instance stays and the data with it
  • viewModelScope: coroutines for loading and saving, cancelled in onCleared() when the screen is finally removed
  • Never holds a reference to a Context, a View or a composable: those are shorter-lived and would leak
  • Exposes state as StateFlow or Compose State, and events as plain functions
class HomeViewModel(private val movieRepository: MovieRepository) : ViewModel() {

    fun deleteMovie(movie: Movie) {
        // viewModelScope is cancelled when the ViewModel is cleared, not when the screen leaves
        viewModelScope.launch {
            movieRepository.delete(movie)
        }
    }
}

24 UI State as StateFlow: HomeViewModel

// ui/home/HomeViewModel.kt (assignment 4)
class HomeViewModel(private val movieRepository: MovieRepository) : ViewModel() {

    /**
     * The UI state of the home screen. The Flow from the repository is converted into a StateFlow
     * that always holds the latest list and is shared while the UI is collecting it.
     */
    val homeUiState: StateFlow<HomeUiState> = movieRepository.getAllMovies()
        .map { HomeUiState(it) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
            initialValue = HomeUiState()
        )

    companion object {
        private const val TIMEOUT_MILLIS = 5_000L
    }
}

data class HomeUiState(val movieList: List<Movie> = emptyList())
  • HomeUiState is an immutable data class: one object describes the whole screen
  • The ViewModel never calls "refresh": Room emits, the state updates, the screen follows

25 The Screen Collects the State: HomeScreen

// ui/home/HomeScreen.kt (assignment 4)
@Composable
fun HomeScreen(
    onSearchClick: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
    // Collects the StateFlow as Compose state, only while the screen is at least STARTED
    val homeUiState by viewModel.homeUiState.collectAsStateWithLifecycle()

    Column(modifier = modifier) {
        Button(onClick = onSearchClick, modifier = Modifier.testTag("openSearchButton")) {
            Text(text = stringResource(R.string.open_search_button))
        }
        MovieList(
            movieList = homeUiState.movieList,
            onMovieClick = { movie -> viewModel.deleteMovie(movie) }
        )
    }
}
  • viewModel() from lifecycle-viewmodel-compose returns the existing instance for this screen or creates one
  • The screen is a pure function of homeUiState; the only thing it does on a click is call the ViewModel

26 Alternative State Holder: mutableStateOf in the ViewModel

// ui/search/SearchViewModel.kt (assignment 4)
class SearchViewModel(private val movieRepository: MovieRepository) : ViewModel() {

    /** The UI state of the search screen. Compose recomposes when it changes. */
    var searchUiState by mutableStateOf(SearchUiState())
        private set

    fun searchMovies(searchString: String) {
        // Static data source, replaced by a REST API in assignment 5
        val results = allMovies.filter { it.title.contains(searchString, ignoreCase = true) }
        searchUiState = SearchUiState(movieList = results)
    }

    fun saveMovie(movie: Movie) {
        viewModelScope.launch {
            movieRepository.insert(movie)
        }
    }
}

data class SearchUiState(val movieList: List<Movie> = emptyList())
  • private set: only the ViewModel changes the state, the screen just reads viewModel.searchUiState
  • Simpler than StateFlow when the state is not derived from a Flow; but it ties the ViewModel to the Compose runtime. Both styles are fine, be consistent within a project

27 Repository Pattern

// model/MovieRepository.kt (assignment 4)
interface MovieRepository {
    fun getAllMovies(): Flow<List<Movie>>
    suspend fun insert(movie: Movie)
    suspend fun delete(movie: Movie)
}

/** Repository implementation that stores the movies in the local Room database. */
class OfflineMovieRepository(private val movieDao: MovieDao) : MovieRepository {
    override fun getAllMovies(): Flow<List<Movie>> = movieDao.getAllMovies()
    override suspend fun insert(movie: Movie) = movieDao.insert(movie)
    override suspend fun delete(movie: Movie) = movieDao.delete(movie)
}
  • The repository is the single source of truth for one kind of data. ViewModels talk to it and do not know whether the data comes from Room, the network or memory
  • Why an interface? The implementation can be swapped: an in-memory FakeMovieRepository in unit tests, an OnlineMovieRepository that caches API results later
  • It is the place for logic that combines sources: "load from the network, store in Room, expose the Room flow"

28 Manual Dependency Injection

Who creates the database, the DAO and the repository, and how does a ViewModel get them? Not the ViewModel itself (it would be untestable) and not every screen (duplicates). One object does it, once:

// MovieTrackerApplication.kt (assignment 4)
class MovieTrackerApplication : Application() {
    lateinit var container: AppContainer

    override fun onCreate() {
        super.onCreate()
        container = AppDataContainer(this)
    }
}

/** Declares which dependencies the app needs. */
interface AppContainer {
    val movieRepository: MovieRepository
}

/** Creates the dependencies lazily, i.e. on first access. */
class AppDataContainer(private val context: Context) : AppContainer {
    override val movieRepository: MovieRepository by lazy {
        OfflineMovieRepository(MovieDatabase.getDatabase(context).movieDao())
    }
}

The Application object lives as long as the process. Register it in the manifest: <application android:name=".MovieTrackerApplication" ...>

29 Creating ViewModels with Dependencies

// ui/AppViewModelProvider.kt (assignment 4)
object AppViewModelProvider {

    val Factory = viewModelFactory {
        initializer {
            HomeViewModel(movieTrackerApplication().container.movieRepository)
        }
        initializer {
            SearchViewModel(movieTrackerApplication().container.movieRepository)
        }
    }
}

/** Extension function that returns the [MovieTrackerApplication] from the ViewModel creation extras. */
fun CreationExtras.movieTrackerApplication(): MovieTrackerApplication =
    (this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as MovieTrackerApplication)
  • A ViewModel with constructor parameters needs a factory; viewModelFactory { initializer { } } builds one with a lambda per ViewModel class
  • CreationExtras carries the Application, the extension function casts it to ours and reaches the container
  • Usage in every screen: viewModel(factory = AppViewModelProvider.Factory)
  • Guide: Manual dependency injection

30 Outlook: Dependency Injection Frameworks

  • Manual DI is explicit and small, but every new dependency means editing the container and the factory
  • Hilt (Google, built on Dagger): annotations @HiltAndroidApp, @HiltViewModel, @Inject, code generation at compile time, hiltViewModel() in Compose
  • Koin: a Kotlin DSL (module { single { ... } viewModel { ... } }), resolved at runtime, no code generation
  • Both remove the factory and the container; the architecture (ViewModel, repository, interfaces) stays exactly the same
  • Not allowed in the project: only Retrofit, Gson, Coil and Google Maps are permitted third-party libraries. The manual container is all you need for its size

31 Alternative Databases

  • SQLDelight (Cash App): you write SQL, it generates type-safe Kotlin; Kotlin Multiplatform
  • Realm Kotlin SDK: an object database, deprecated by MongoDB together with the Atlas Device SDKs; only a community branch remains. Do not start new projects with it
  • GreenDAO, ActiveAndroid, ORMLite: abandoned ORMs you will find in old tutorials
  • For the project: Room is the Jetpack default and the one covered by the course

32 Cloud Storage Providers

  • Backend-as-a-Service providers store data on a server and ship Android SDKs for authentication, database and file storage, e.g. Firebase (Google), Supabase (open source, Postgres), back4app (Parse)
  • Any REST API reached with Retrofit (lecture 12) is "cloud storage" too
  • Think about offline behaviour: cache remote data in Room and sync, do not block the UI on the network
  • Do not use them in the project!

33 Assignment 4

Assignment 4: Introduction to MVVM and the Room database

What you need from this lecture for it:

  • Room: @Entity with @PrimaryKey(autoGenerate = true), the @Dao interface with a Flow<List<Movie>> query and suspend insert / delete, the @Database singleton (@Volatile + synchronized, database name movie_database)
  • The architecture of the starter: MovieTrackerApplication → AppDataContainer → OfflineMovieRepository → AppViewModelProvider.Factory → ViewModels → Screens
  • viewModelScope.launch { movieRepository.insert(movie) } in saveMovie() / deleteMovie() (lecture 10 explains why not rememberCoroutineScope())
  • The Database Inspector to check what is actually in the table

34 Summary

  • Small settings: Shared Preferences, or Jetpack DataStore when you want Flow and no blocking calls
  • Files: internal storage (filesDir, cacheDir) is private and needs no permission; app-specific external storage (getExternalFilesDir()) too. Shared media and documents go through MediaStore, the Photo Picker and the Storage Access Framework, the legacy storage permissions are gone
  • Structured data: SQLite, almost always through Room: entity, DAO, database singleton, Flow + suspend, versions and migrations
  • Architecture: UI → ViewModel → Repository → data sources, state down, events up
  • ViewModel survives rotation and owns viewModelScope; StateFlow<UiState> + stateIn + collectAsStateWithLifecycle() connects it to Compose
  • The repository interface is the single source of truth and the seam for tests
  • Manual DI: Application owns an AppContainer, a viewModelFactory hands the dependencies to the ViewModels

35 Recap Questions

  • Which storage option would you choose for: a dark-mode flag, a cached API response, a photo the user took, a list of saved movies?
  • Why do WRITE_EXTERNAL_STORAGE and READ_EXTERNAL_STORAGE no longer help you, and what do you use instead?
  • What does Room generate for you, and what do you have to write yourself?
  • Why does the DAO return a Flow<List<Movie>> instead of a List<Movie>?
  • What happens when you change an entity without raising the database version?
  • Why does the ViewModel survive a rotation, but a remember { } in the composable does not?
  • Why is MovieRepository an interface and not just a class?
  • Who creates OfflineMovieRepository, and how does HomeViewModel get hold of it?

Questions?