Open menu with table of contents Android Assignment 04
Logo of Stuttgart Media University for light theme Logo of Stuttgart Media University for dark theme
Android Development

Android Assignment 04

Introduction to MVVM and the Room database

Stuttgart Media University

Please conduct the following tasks alone. For implementation details you can refer to the lecture slides or the Android developer website. Please do not hesitate to ask me or the tutor if you have any questions.

1 Tasks

  • Copy the starter project assignmentCode/assignment4 into the assignment4 folder of your Git repository. The starter is the solution of assignment 3, restructured to the MVVM architecture pattern: the screens no longer hold the data, ViewModels do, and the saved movies are stored in a Room database.
  • Goal: saved movies survive an app restart. Verify it: save a movie, close the app completely (swipe it away in the recent apps), start it again, the movie is still there.
  • Implement the TODOs of the starter:
    1. model/MovieDao.kt: the three DAO methods (getAllMovies() as Flow, insert(), delete()).
    2. model/MovieDatabase.kt: the abstract movieDao() function and the getDatabase() singleton. Keep the database name movie_database, the test checks that the file exists.
    3. ui/home/HomeViewModel.kt: deleteMovie().
    4. ui/search/SearchViewModel.kt: saveMovie().
    5. ui/search/SearchScreen.kt: save the clicked movie and navigate back.
  • The project does not compile until the DAO and the database are implemented. The compiler errors point to the places that use them.
  • Run the UI test to validate your implementation.

2 The architecture of the starter

MovieTrackerApplication      creates the AppContainer once per app process
└── AppDataContainer         builds MovieDatabase -> MovieDao -> OfflineMovieRepository (lazily)

ui/AppViewModelProvider      Factory that creates the ViewModels and hands them the repository
ui/home/HomeViewModel        homeUiState: StateFlow<HomeUiState>, deleteMovie()          <- TODO
ui/home/HomeScreen           collects homeUiState with collectAsStateWithLifecycle()
ui/search/SearchViewModel    searchUiState with the results, searchMovies(), saveMovie()   <- TODO
ui/search/SearchScreen       TextField, Button, MovieList                                  <- TODO

model/Movie                  @Entity with an auto-generated id
model/MovieDao               @Dao interface, Room generates the implementation            <- TODO
model/MovieDatabase          @Database, singleton                                         <- TODO
model/MovieRepository        interface + OfflineMovieRepository(movieDao)

The data flows in one direction: Room emits a Flow<List<Movie>> -> the repository passes it on -> the HomeViewModel turns it into a StateFlow<HomeUiState> -> the HomeScreen collects it and recomposes. User actions go the other way: HomeScreen -> viewModel.deleteMovie() -> repository -> DAO.

3 Hints

  • DAO: @Query("SELECT * FROM movie ORDER BY id") fun getAllMovies(): Flow<List<Movie>>. A Flow return type makes Room emit a new list whenever the table changes, so the home screen updates itself. insert and delete are suspend functions annotated with @Insert and @Delete.
  • Database: Room.databaseBuilder(context, MovieDatabase::class.java, DATABASE_NAME).build(). Create the instance only once and keep it in the companion object (@Volatile field + synchronized), see the lecture "Storage Options".
  • ViewModel: insert and delete are suspend functions, call them inside viewModelScope.launch { ... }. The viewModelScope is cancelled when the ViewModel is destroyed, not when the screen is left, so the insert finishes even though navigateBack() is called right after saveMovie().
  • Order: the test expects the saved movies in the order they were saved. ORDER BY id with an auto-generated id does exactly that.
  • Empty state: the test expects an empty database on the first start. Uninstall the app on the emulator before running the test (long-press the app icon or adb uninstall de.hdmstuttgart.movietracker).
  • Inspect the database: Android Studio > App Inspection > Database Inspector shows the content of the table while the app runs.

4 UI test

The test (Assignment4Test.kt) consists of three test methods that run in alphabetical order. Every method starts a new MainActivity, so the movies saved in test1 can only be visible in test2 and test3 when they come from the database.

package de.hdmstuttgart.movietracker

import android.content.Context
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertTrue
import org.junit.FixMethodOrder
import org.junit.Rule
import org.junit.Test
import org.junit.runners.MethodSorters

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class Assignment4Test {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    /** Waits until exactly [count] movie items are shown (the database delivers asynchronously). */
    private fun waitForMovieItems(count: Int) {
        composeTestRule.waitUntil(timeoutMillis = TIMEOUT) {
            composeTestRule.onAllNodesWithTag("movieItem").fetchSemanticsNodes().size == count
        }
    }

    private fun searchAndSaveFirstResult(query: String) {
        composeTestRule.onNodeWithTag("openSearchButton").performClick()
        composeTestRule.onNodeWithTag("textField").performTextInput(query)
        composeTestRule.onNodeWithTag("searchButton").performClick()
        composeTestRule.onAllNodesWithTag("movieItem")[0].performClick()
    }

    @Test
    fun test1_saveMovies() {
        searchAndSaveFirstResult("Gold")
        waitForMovieItems(1)
        composeTestRule.onAllNodesWithTag("movieItem")[0].assertTextContains("Goldfinger")

        searchAndSaveFirstResult("From Russia with Love")
        waitForMovieItems(2)
        composeTestRule.onAllNodesWithTag("movieItem")[0].assertTextContains("Goldfinger")
        composeTestRule.onAllNodesWithTag("movieItem")[1].assertTextContains("From Russia with Love")
    }

    @Test
    fun test2_moviesArePersisted() {
        waitForMovieItems(2)
        composeTestRule.onAllNodesWithTag("movieItem")[0].assertTextContains("Goldfinger")
        composeTestRule.onAllNodesWithTag("movieItem")[1].assertTextContains("From Russia with Love")

        val context = ApplicationProvider.getApplicationContext<Context>()
        assertTrue(
            "Room database file 'movie_database' not found",
            context.getDatabasePath("movie_database").exists()
        )
    }

    @Test
    fun test3_deleteMovie() {
        waitForMovieItems(2)
        composeTestRule.onAllNodesWithTag("movieItem")[0].performClick()
        waitForMovieItems(1)
        composeTestRule.onAllNodesWithTag("movieItem")[0].assertTextContains("From Russia with Love")
    }

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

5 Gradle dependencies

Room and the KSP compiler plugin are already configured in the starter. These are the relevant lines:

gradle/libs.versions.toml:

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

[libraries]
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
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:

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

Module app/build.gradle.kts:

plugins {
    // ...
    alias(libs.plugins.ksp)
}

dependencies {
    implementation(libs.androidx.lifecycle.runtime.compose)   // collectAsStateWithLifecycle()
    implementation(libs.androidx.lifecycle.viewmodel.compose) // viewModel(factory = ...)
    implementation(libs.androidx.room.runtime)
    ksp(libs.androidx.room.compiler)                           // generates the DAO and database code
    // ...
}

Submission

  • Push the project to the assignment4 folder of your Git repository (assignment4/app/src/main/...).
  • For grading, your Assignment4Test.kt is replaced by the reference version and executed on a fresh emulator (the app is uninstalled first, so the database is empty).