context.getSharedPreferences(name, Context.MODE_PRIVATE): one file per namegetBoolean(), getString(), getInt(), ... the second parameter is the default valueSharedPreferences.Editor obtained with edit()putBoolean(), putString(), ... then apply() (asynchronous, preferred) or commit() (blocking, returns success)androidx.core KTX extension edit { } does this for youval 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 blockedThe 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 }
}
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version = "1.2.1" }context.filesDir: persistent files (/data/data/<package>/files)context.cacheDir: files the system may delete when storage runs low; clean up yourself tooopenFileOutput(name, mode), returns a FileOutputStreamContext.MODE_PRIVATE creates or replaces the fileContext.MODE_APPEND appends to an existing fileopenFileInput(name), returns a FileInputStreamres/raw/ and are opened with
resources.openRawResource(R.raw.filename)// 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!")
Dispatchers.IO (see lecture 10), never on the main threadFile API also works for context.cacheDir and the external app-specific directoriesWRITE_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)| 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 |
// 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 directoryMediaStore does not index these files, other apps and the gallery do not see themThe 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")
}
}
content:// Uri; load it with Coil (AsyncImage(model = uri, ...)) or open it
with context.contentResolver.openInputStream(uri)PickMultipleVisualMedia(maxItems = 5) for several itemsMediaStore.Images.Media so the gallery sees themval 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)Uri; call takePersistableUriPermission() to keep it after a restartTakePicture,
pictures via Photo Picker or MediaStore, files via the Storage Access FrameworkonCreate() (execute CREATE TABLE statements) and onUpgrade() (schema changes
between versions)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)
}
}
writableDatabase or readableDatabase on the helper; both return a SQLiteDatabaseinsert(), update(), delete() take a ContentValues map, query() and rawQuery() return a
Cursor pointing to the result rowscursor.getString(cursor.getColumnIndexOrThrow("title"))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")
})
Flow and supports suspend functions out of the box: the main thread is never blockedgradle/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.
@Entity, the class name is the table name (tableName = ... to change it)@PrimaryKey(autoGenerate = true) lets SQLite assign increasing ids@ColumnInfo(name = ...) renames, @Ignore excludes a propertyString, ByteArray; everything else needs a TypeConverter// 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
)
interface (or abstract class) annotated with @Dao, Room generates the implementation@Insert, @Update, @Delete generate the SQL from the entity; @Query takes your own SQLsuspend functions: Room runs them on its own background executor@Query returning Flow<List<Movie>> is observable: a new list is emitted after every change to the table// 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)
}
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 elseabstract class extending RoomDatabase, annotated with @Database(entities, version)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 }
}
}
}
}
version; otherwise Room
throws an IllegalStateException on start because the stored schema does not matchfallbackToDestructiveMigration(dropAllTables = true) drops all data and recreates
the tables when the version changes. This is what assignment 5 does (version = 2)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 @AutoMigrationRoom.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
insert() and delete() did/data/data/de.hdmstuttgart.movietracker/databases/movie_database
(context.getDatabasePath(name), the assignment 4 test checks that it exists)Until assignment 3 the screens held the data in remember { mutableStateListOf() }. That breaks down quickly:
@Composable functions needs an emulator to be testedGoogle's guide to app architecture answers this with layers and unidirectional data flow. Assignment 4 applies it to 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)
Flow<List<Movie>>, the repository passes it on, the ViewModel
turns it into StateFlow<HomeUiState>, the screen collects it and recomposesHomeScreen calls viewModel.deleteMovie(movie), the ViewModel
calls the repository, the repository the DAO, Room emits the new list and the circle closesViewModelandroidx.lifecycle.ViewModel: a state holder scoped to a screen (Activity, Fragment or
navigation back stack entry)viewModelScope: coroutines for loading and saving, cancelled in onCleared() when the screen is finally removedContext, a View or a composable: those are shorter-lived and would leakStateFlow or Compose State, and events as plain functionsclass 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)
}
}
}
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 screenHomeScreen// 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 onehomeUiState; the only thing it does on a click is call the ViewModelmutableStateOf 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.searchUiStateStateFlow 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// 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)
}
FakeMovieRepository in
unit tests, an OnlineMovieRepository that caches API results laterWho 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" ...>
// 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)
viewModelFactory { initializer { } }
builds one with a lambda per ViewModel classCreationExtras carries the Application, the extension function casts it to ours and reaches the containerviewModel(factory = AppViewModelProvider.Factory)@HiltAndroidApp, @HiltViewModel, @Inject, code generation at compile time,
hiltViewModel() in Composemodule { single { ... } viewModel { ... } }),
resolved at runtime, no code generationAssignment 4: Introduction to MVVM and the Room database
What you need from this lecture for it:
@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)MovieTrackerApplication → AppDataContainer →
OfflineMovieRepository → AppViewModelProvider.Factory → ViewModels → ScreensviewModelScope.launch { movieRepository.insert(movie) } in saveMovie() / deleteMovie()
(lecture 10 explains why not rememberCoroutineScope())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 goneFlow + suspend, versions and migrationsViewModel survives rotation and owns viewModelScope; StateFlow<UiState> + stateIn +
collectAsStateWithLifecycle() connects it to ComposeApplication owns an AppContainer, a viewModelFactory hands the dependencies to the ViewModelsWRITE_EXTERNAL_STORAGE and READ_EXTERNAL_STORAGE no longer help you, and what do you use instead?Flow<List<Movie>> instead of a List<Movie>?remember { } in the composable does not?MovieRepository an interface and not just a class?OfflineMovieRepository, and how does HomeViewModel get hold of it?