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.
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.TODOs of the starter:model/MovieDao.kt: the three DAO methods (getAllMovies() as Flow, insert(), delete()).model/MovieDatabase.kt: the abstract movieDao() function and the getDatabase() singleton. Keep the
database name movie_database, the test checks that the file exists.ui/home/HomeViewModel.kt: deleteMovie().ui/search/SearchViewModel.kt: saveMovie().ui/search/SearchScreen.kt: save the clicked movie and navigate back.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.
@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.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".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 BY id with an auto-generated
id does exactly that.adb uninstall de.hdmstuttgart.movietracker).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
}
}
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
// ...
}
assignment4 folder of your Git repository (assignment4/app/src/main/...).Assignment4Test.kt is replaced by the reference version and executed on a fresh emulator
(the app is uninstalled first, so the database is empty).