REST services
REST client repository
Now we'll add a new repository implementation (using the existing interface) to communicate with the server.
Note
This code is part of the Android application, not the rest server! Remember that we usually wouldn't place the rest server code in this project.
Our Android client will use Retrofit 2 to communicate with the server, so we add the version, dependencies, and another bundle to our version catalog. Sync the application.
show in full file gradle/libs.versions.toml
[versions]
// ...
activation="2.1.3"
retrofit = "2.9.0"
accompanistPermissions = "0.37.3"
// ...
[libraries]
// ...
activation = { group = "jakarta.activation", name = "jakarta.activation-api", version.ref = "activation" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-json = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
com-google-accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" }
// ...
[bundles]
// ...
"activation"
]
retrofit = [
"retrofit",
"retrofit-json"
]
[plugins]
// ...
We add a bundle reference to the repository module's build script (and sync the app again)
show in full file repository/build.gradle.kts
// ...
dependencies {
implementation(project(":data"))
implementation(libs.bundles.retrofit)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
// ...
}
Retrofit allows you to define REST calls using an annotated interface. The function definitions define parameters and return values, annotated with the server path used to access that data.
Our functions return a Response instance that includes the status as well as data (if the
call was successful).
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieApiService.kt
// ...
import retrofit2.http.Path
interface MovieApiService {
@GET("movie")
suspend fun getMovies(): Response<List<MovieDto>>
@GET("actor")
suspend fun getActors(): Response<List<ActorDto>>
@GET("rating")
suspend fun getRatings(): Response<List<RatingDto>>
@GET("rating/{id}/movies")
suspend fun getRatingWithMovies(@Path("id") id: String): Response<RatingWithMoviesDto?>
@GET("movie/{id}/cast")
suspend fun getMovieWithCast(@Path("id") id: String): Response<MovieWithCastDto?>
@GET("actor/{id}/filmography")
suspend fun getActorWithFilmography(@Path("id") id: String): Response<ActorWithFilmographyDto?>
@GET("movie/{id}")
suspend fun getMovie(@Path("id") id: String): Response<MovieDto?>
@GET("actor/{id}")
suspend fun getActor(@Path("id") id: String): Response<ActorDto?>
@GET("rating/{id}")
suspend fun getRating(@Path("id") id: String): Response<RatingDto?>
@POST("movie")
suspend fun createMovie(@Body movie: MovieDto): Response<MovieDto>
@POST("actor")
suspend fun createActor(@Body actor: ActorDto): Response<ActorDto>
@POST("rating")
suspend fun createRating(@Body rating: RatingDto): Response<RatingDto>
@PUT("movie/{id}")
suspend fun updateMovie(
@Path("id") id: String,
@Body movie: MovieDto
): Response<Int> // number updated
@PUT("actor/{id}")
suspend fun updateActor(
@Path("id") id: String,
@Body actor: ActorDto
): Response<Int> // number updated
@PUT("rating/{id}")
suspend fun updateRating(
@Path("id") id: String,
@Body rating: RatingDto
): Response<Int> // number updated
@DELETE("rating/{id}")
suspend fun deleteRating(
@Path("id") id: String,
): Response<Int> // number deleted
@DELETE("movie/{id}")
suspend fun deleteMovie(
@Path("id") id: String,
): Response<Int> // number deleted
@DELETE("actor/{id}")
suspend fun deleteActor(
@Path("id") id: String,
): Response<Int> // number deleted
@GET("reset")
suspend fun reset(): Response<Int>
companion object {
fun create(serverBaseUrl: String): MovieApiService =
Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(serverBaseUrl)
.build()
.create(MovieApiService::class.java)
}
}
We're passing in the server base URL to create(). This allows the main application
to control the URL, which could be obtained from the function we defined for debug and
release configurations, or a user setting defined in the UI.
Retrofit will automatically marshall the @Body parameter objects to JSON, and unmarshall the
returned JSON back into objects.
Now we define the new concrete repository implementation
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieRestRepository.kt
// ...
import kotlin.reflect.KProperty
class MovieRestRepository(
private val serverBaseUrl: String,
): MovieRepository {
// create a coroutine scope that will last for the duration of the application
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// We'll be dealing with many flows to manage data retrieved from the server.
// To make this a bit cleaner, I'm wrapping the flow and the code to fetch
// its data in a helper class I'll call a FlowManager. You don't need to do
// this; you could just track the flows separately as we have been doing.
// For example, for the movies list, we'd need
// private val _movies = MutableStateFlow<List<MovieDto>>(emptyList)
// val movies: Flow<List<MovieDto>> = _movies.asStateFlow()
// fun fetchMovies() {
// coroutineScope.launch(Dispatchers.IO) {
// _movies.value =
// movieApiService
// .getMovies()
// .takeIf { it.isSuccessful }
// ?.body()
// ?: emptyList()
// }
// }
// FlowManager is a kotlin property delegate (it provides the necessary
// getValue() function) and can be used with a "by" to automatically
// expose the Flow
// We also track all flow managers in a list so we can walk through to
// re-fetch them. We could be more efficient by using keys to only
// update flows that need it, but for this example, updaing all flows
// is sufficient
sealed interface FlowId
data object Movies: FlowId
data object Actors: FlowId
data object Ratings: FlowId
data object MovieWithCast: FlowId
data object RatingWithMovies: FlowId
data object ActorWithFilmography: FlowId
private val allFlowManagers = mutableMapOf<FlowId, FlowManager<*>>()
private fun fetchAll() =
allFlowManagers.values.forEach { it.fetch() }
fun <T> flowListManager(
id: FlowId,
fetcher: suspend () -> Response<List<T>>
) = FlowManager(id, emptyList(), fetcher)
fun <T> flowManager(
id: FlowId,
fetcher: suspend () -> Response<T?>
) = FlowManager(id, null, fetcher)
inner class FlowManager<T>(
id: FlowId,
private val defaultValue: T,
private val fetcher: suspend () -> Response<T>
) {
private val _flow = MutableStateFlow(defaultValue)
val flow: Flow<T> = _flow.asStateFlow()
init {
allFlowManagers[id] = this
fetch()
}
fun fetch() =
coroutineScope.launch {
_flow.value = fetcher().takeIf { it.isSuccessful }?.body() ?: defaultValue
}
operator fun getValue(thisRef: Any?, property: KProperty<*>): Flow<T> {
return flow
}
}
private val movieApiService = MovieApiService.create(serverBaseUrl)
override val ratingsFlow by flowListManager(Ratings) { movieApiService.getRatings() }
override val moviesFlow by flowListManager(Movies) { movieApiService.getMovies() }
override val actorsFlow by flowListManager(Actors) { movieApiService.getActors() }
// NOTE: The following assume that only one of each is active at a time
// For our app, that should be the case (the edit screens don't allow
// any deeper navigation), but if you wanted to be more general, you
// could set up a WeakHashMap where the key is the flow and the value
// is the manager (which also has a weak reference to the Flow).
// Weak references allow the object they point to be garbage collected
// when there are no remaining strong references to it, and they'll be removed
// from the map when no longer referenced. You would then walk through all the
// values in the map and call their fetch() functions.
override fun getRatingWithMoviesFlow(id: String) =
flowManager(RatingWithMovies) { movieApiService.getRatingWithMovies(id) }.flow
override fun getMovieWithCastFlow(id: String): Flow<MovieWithCastDto?> =
flowManager(MovieWithCast) { movieApiService.getMovieWithCast(id) }.flow
override fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmographyDto?> =
flowManager(ActorWithFilmography) { movieApiService.getActorWithFilmography(id) }.flow
private suspend fun <T> getOrError(
id: String,
fetch: suspend MovieApiService.(String) -> Response<T?>
): T = withContext(Dispatchers.IO) {
movieApiService.fetch(id).takeIf {
val code = it.code() // example of how to get the HTTP status
it.isSuccessful
}?.body()
?: throw RuntimeException("$id not found")
}
override suspend fun getRatingWithMovies(id: String) =
getOrError(id) { movieApiService.getRatingWithMovies(it) }
override suspend fun getMovieWithCast(id: String) =
getOrError(id) { movieApiService.getMovieWithCast(it) }
override suspend fun getActorWithFilmography(id: String) =
getOrError(id) { movieApiService.getActorWithFilmography(it) }
override suspend fun getRating(id: String): RatingDto =
getOrError(id) { movieApiService.getRating(it) }
override suspend fun getMovie(id: String) =
getOrError(id) { movieApiService.getMovie(it) }
override suspend fun getActor(id: String): ActorDto =
getOrError(id) { movieApiService.getActor(it) }
override suspend fun insert(movie: MovieDto) {
movieApiService.createMovie(movie)
fetchAll()
}
override suspend fun insert(actor: ActorDto) {
movieApiService.createActor(actor)
fetchAll()
}
override suspend fun insert(rating: RatingDto) {
movieApiService.createRating(rating)
fetchAll()
}
override suspend fun upsert(movie: MovieDto) {
movieApiService.updateMovie(movie.id, movie)
fetchAll()
}
override suspend fun upsert(actor: ActorDto) {
movieApiService.updateActor(actor.id, actor)
fetchAll()
}
override suspend fun upsert(rating: RatingDto) {
movieApiService.updateRating(rating.id, rating)
fetchAll()
}
private suspend fun deleteById(
ids: Set<String>,
delete: suspend MovieApiService.(String) -> Unit,
) {
ids.forEach { id ->
movieApiService.delete(id)
}
fetchAll()
}
override suspend fun deleteMoviesById(ids: Set<String>) {
deleteById(ids) { deleteMovie(it) }
}
override suspend fun deleteActorsById(ids: Set<String>) {
deleteById(ids) { deleteActor(it) }
}
override suspend fun deleteRatingsById(ids: Set<String>) {
deleteById(ids) { deleteRating(it) }
}
override suspend fun resetDatabase() {
movieApiService.reset()
fetchAll()
}
companion object {
fun create(serverBaseUrl: String) =
MovieRestRepository(serverBaseUrl)
}
}
I've defined a helper, FlowManager to simplify this repository. The FlowManager exposes a
Flow that can be collected by a caller. (We see the similar private/public property pair
so we can define a MutableStateFlow that can only be internally updated).
The FlowManager also defines a constructor parameter, fetcher, which is a function passed
in to specify how we request data from the server. This is called from the fetch() function,
which launches a coroutine to perform the server request and drops the resulting data inside the
Flow. For this example, we don't perform any error processing; if something goes wrong, we just
emit the default value.
We use the FlowManager to define the Flows required of MovieRepository. Any
functions that request data modification on the server will call fetch on the affected
FlowManagers to grab the data and trigger UI updates.
Note that we must make a slight tweak in our existing MovieRepository. Because we're using
a MutableStateFlow to emit the results of the REST call, we need an initial value; we'll use
null. This means that the returned Flow from getRatingWithMoviesFlow() will be nullable.
(There are other ways to do this is we wanted to keep the original interface contract, but
MutableStateFlow is much simpler to use overall)
show in full file repository/src/main/java/com/androidbyexample/movies/repository/MovieRepository.kt
// ...
interface MovieRepository {
// ...
fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto?>
fun getMovieWithCastFlow(id: String): Flow<MovieWithCastDto?>
fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmographyDto?>
suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto
// ...
}
Finally, we switch the repo we're using with the view model:
show in full file app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
// ...
class MovieViewModel(
// ...
): ViewModel(), MovieRepository by repository {
// ...
companion object {
val Factory = viewModelFactory {
initializer {
// ...
MovieViewModel(
savedStateHandle = savedStateHandle,
// repository = MovieDatabaseRepository.create(application)
repository = MovieRestRepository.create(getServerAddress())
)
}
}
}
}
Now, we can run the application. First, start the server:
./gradlew run
Once it reports
INFO: [HttpServer] Started
When we try to run the application, it will crash. Looking at the Logcat view in Studio, we'll see
java.net.UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted by network security policy
This is telling us that Android wants us to use HTTPS instead of HTTP to encrypt communication.
I don't want to go into details on setting up Secure-Socket Layer (SSL) communication in this application. You can find that online if interested. For this sample, we'll just allow the unsecure comms.
Warning
Do not do this in real applications!!!
We set this up in the manifest by adding android:usesCleartextTraffic="true" to our
<application> tag.
show in full file app/src/main/AndroidManifest.xml
// ...
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
// ...
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Movies">
<activity
// ...
</application>
// ...
</manifest>
If we run the application now, it still doesn't work!. We'll get the cryptic
java.net.SocketException: socket failed: EPERM (Operation not permitted)
message. This is because we haven't told Android that we want to allow network communication.
We do this by requesting INTERNET permission in the manifest
show in full file app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application
// ...
</manifest>
The INTERNET permission is not a "dangerous" permission, so we don't need to request access
at runtime, just request it in the manifest.
Now you can run the application as normal, and the data will come from the server instead of a local database.
Running on Android 17 (API 37)
Warning
If you target API 37 (Android 17) or above, and are trying to access data from the local network (as we are when using our simple rest server), this won't work. By default, Android 37 blocks local network access due to privacy concerns. To fix this, we must request the ACCESS_LOCAL_NETWORK permission, which is a "dangerous" permission, requiring us to perform a runtime check.
We'll use the Accompanist permissions library to make the permission request simpler and more in line with our Compose user interface.
Note
The Accompanist permission support is a little weak at the moment. It cannot distinguish between the initial request for permissions or when the user has permanently-denied the request. To get around this, I provide two buttons on the UI, one to request the permission and one to go to application settings. In a real app, you may want to look closer at the runtime permissions docs to find a better way to handle this.
Let's import the Accompanist permissions library. First, the version catalog:
show in full file gradle/libs.versions.toml
[versions]
// ...
retrofit = "2.9.0"
accompanistPermissions = "0.37.3"
[libraries]
// ...
retrofit-json = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
com-google-accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" }
[bundles]
// ...
[plugins]
// ...
Then add the library to the app's build script:
show in full file app/build.gradle.kts
// ...
dependencies {
implementation(project(":repository"))
implementation(libs.com.google.accompanist.permissions)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
// ...
}
And we use it to ask for permissions by calling rememberPermissionState() and using its status to decide what to show in the user interface.
show in full file app/src/main/java/com/androidbyexample/movies/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
@OptIn(ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
MoviesTheme {
val localNetworkPermissionState = rememberPermissionState(
android.Manifest.permission.ACCESS_LOCAL_NETWORK
)
if (localNetworkPermissionState.status.isGranted) {
Ui(viewModel = viewModel)
} else {
val messageId =
if (localNetworkPermissionState.status.shouldShowRationale)
R.string.cannot_run
else
R.string.ask_local_network_permission
DetailPlaceholder(
messageId = messageId,
) {
Button(
onClick = { localNetworkPermissionState.launchPermissionRequest() },
modifier = Modifier.padding(8.dp)
) {
Label(R.string.request_permission)
}
Display(stringResource(R.string.app_info))
Button(
onClick = {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
)
},
modifier = Modifier.padding(8.dp)
) {
Label(R.string.go_to_app_info)
}
}
}
}
}
}
}
Now the application works on Android 17 and beyond!
Note
When checking for runtime permissions, check as close to the point of use as possible. This makes it more clear to the user why a permission is needed, and also makes it simpler to have a fallback if the permission is granted (or entirely omit a feature). In this example, the permission is needed at application startup, so we wrap the top-level Ui with the permission check.
All code changes
CHANGED: app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
id("kotlin-parcelize")
}
android {
namespace = "com.androidbyexample.movies"
compileSdk {
version = release((libs.versions.compileSdk.get().toInt()))
}
defaultConfig {
applicationId = "com.androidbyexample.movies"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
optimization {
enable = false
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get())
targetCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get())
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(project(":repository"))
implementation(libs.com.google.accompanist.permissions)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.material3.adaptive.navigation3)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}
CHANGED: app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Movies">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Movies"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
CHANGED: app/src/main/java/com/androidbyexample/movies/MainActivity.kt
package com.androidbyexample.movies
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.androidbyexample.movies.helper.Display
import com.androidbyexample.movies.helper.Label
import com.androidbyexample.movies.screens.DetailPlaceholder
import com.androidbyexample.movies.screens.Ui
import com.androidbyexample.movies.ui.theme.MoviesTheme
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MovieViewModel> { MovieViewModel.Factory }
@OptIn(ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MoviesTheme {
val localNetworkPermissionState = rememberPermissionState(
android.Manifest.permission.ACCESS_LOCAL_NETWORK
)
if (localNetworkPermissionState.status.isGranted) {
Ui(viewModel = viewModel)
} else {
val messageId =
if (localNetworkPermissionState.status.shouldShowRationale)
R.string.cannot_run
else
R.string.ask_local_network_permission
DetailPlaceholder(
messageId = messageId,
) {
Button(
onClick = { localNetworkPermissionState.launchPermissionRequest() },
modifier = Modifier.padding(8.dp)
) {
Label(R.string.request_permission)
}
Display(stringResource(R.string.app_info))
Button(
onClick = {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
)
},
modifier = Modifier.padding(8.dp)
) {
Label(R.string.go_to_app_info)
}
}
}
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
package com.androidbyexample.movies
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.androidbyexample.movies.repository.ActorDto
//import com.androidbyexample.movies.repository.MovieDatabaseRepository
import com.androidbyexample.movies.repository.MovieDto
import com.androidbyexample.movies.repository.MovieRepository
import com.androidbyexample.movies.repository.MovieRestRepository
import com.androidbyexample.movies.repository.RatingDto
import com.androidbyexample.movies.screens.ListScreen
import com.androidbyexample.movies.screens.MovieList
import com.androidbyexample.movies.screens.Screen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MovieViewModel(
private val savedStateHandle: SavedStateHandle,
private val repository: MovieRepository,
): ViewModel(), MovieRepository by repository {
val currentListScreenFlow = savedStateHandle.getMutableStateFlow<ListScreen>(
key = "current_list_screen",
initialValue = MovieList
)
fun goToListScreen(screen: ListScreen) {
currentListScreenFlow.value = screen
backStackFlow.value = listOf(screen)
}
val backStackFlow = savedStateHandle.getMutableStateFlow<List<Screen>>(
key = "back_stack",
initialValue = listOf(MovieList)
)
fun pushScreen(screen: Screen) {
backStackFlow.value += screen
}
fun popScreen() {
backStackFlow.value = backStackFlow.value.dropLast(1)
}
fun doResetDatabase() {
viewModelScope.launch(Dispatchers.IO) {
repository.resetDatabase()
}
}
fun deleteSelectedMovies(ids: Set<String>) {
viewModelScope.launch {
deleteMoviesById(ids)
}
}
fun deleteSelectedActors(ids: Set<String>) {
viewModelScope.launch {
deleteActorsById(ids)
}
}
fun deleteSelectedRatings(ids: Set<String>) {
viewModelScope.launch {
deleteRatingsById(ids)
}
}
fun update(movie: MovieDto) {
viewModelScope.launch {
upsert(movie)
}
}
fun update(actor: ActorDto) {
viewModelScope.launch {
upsert(actor)
}
}
fun update(rating: RatingDto) {
viewModelScope.launch {
upsert(rating)
}
}
companion object {
val Factory = viewModelFactory {
initializer {
val application =
checkNotNull(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY])
val savedStateHandle = this.createSavedStateHandle()
MovieViewModel(
savedStateHandle = savedStateHandle,
// repository = MovieDatabaseRepository.create(application)
repository = MovieRestRepository.create(getServerAddress())
)
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/DetailPlaceholder.kt
package com.androidbyexample.movies.screens
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
//import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.androidbyexample.movies.helper.Display
CHANGED: app/src/main/res/values/strings.xml
<resources>
<string name="app_name">movies</string>
<string name="movies">Movies</string>
<string name="actors">Actors</string>
<string name="ratings">Ratings</string>
<string name="actor">Actor</string>
<string name="rating">Rating</string>
<string name="loading">Loading…</string>
<string name="name">Name</string>
<string name="movies_starring">Movies Starring %1$s</string>
<string name="movies_rated">Movies Rated %1$s</string>
<string name="title">Title</string>
<string name="description">Description</string>
<string name="movie">Movie</string>
<string name="reset_database">Reset database</string>
<string name="cast">Cast</string>
<string name="cast_entry">%1$s: %2$s</string>
<string name="clear_selections">Clear Selections</string>
<string name="delete_selected_items">Delete selected items</string>
<string name="select_a_movie_to_view">Select a movie to view</string>
<string name="select_an_actor_to_view">Select an actor to view</string>
<string name="select_a_rating_to_view">Select a rating to view</string>
<string name="edit">Edit</string>
<string name="save">Save</string>
<string name="cannot_run">This application cannot run without access to the local network. Please grant it or you cannot continue.</string>
<string name="ask_local_network_permission">This application accesses a server on your local network. Please grant acceess when asked.</string>
<string name="request_permission">Request permission</string>
<string name="app_info">If the request permissions button does not work (because you have previously denied it), go to the app info page and grant the "Nearby Devices" permission</string>
<string name="go_to_app_info">Go to app info</string>
</resources>
CHANGED: gradle/libs.versions.toml
[versions]
agp = "9.2.1"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0"
kotlin = "2.4.0"
composeBom = "2026.06.01"
navigation = "1.1.4"
material3AdaptiveNav3 = "1.3.0-rc01"
appcompat = "1.7.1"
material = "1.14.0"
room = "2.8.4"
ksp = "2.3.9"
compileSdk = "37"
targetSdk = "37"
minSdk = "24"
javaVersion = "VERSION_11"
jetbrainsKotlinJvm = "2.4.0"
jersey="3.1.9"
activation="2.1.3"
retrofit = "2.9.0"
accompanistPermissions = "0.37.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation" }
androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
jersey-grizzly2 = { group = "org.glassfish.jersey.containers", name = "jersey-container-grizzly2-http", version.ref = "jersey" }
jersey-jetty = { group = "org.glassfish.jersey.containers", name = "jersey-container-jetty-http", version.ref = "jersey" }
jersey-servlet = { group = "org.glassfish.jersey.containers", name = "jersey-container-servlet-core", version.ref = "jersey" }
jersey-jackson = { group = "org.glassfish.jersey.media", name = "jersey-media-json-jackson", version.ref = "jersey" }
jersey-server = { group = "org.glassfish.jersey.core", name = "jersey-server", version.ref = "jersey" }
jersey-hk2 = { group = "org.glassfish.jersey.inject", name = "jersey-hk2", version.ref = "jersey" }
activation = { group = "jakarta.activation", name = "jakarta.activation-api", version.ref = "activation" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-json = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
com-google-accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" }
[bundles]
server = [
"jersey-grizzly2",
"jersey-jetty",
"jersey-servlet",
"jersey-jackson",
"jersey-server",
"jersey-hk2",
"activation"
]
retrofit = [
"retrofit",
"retrofit-json"
]
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }
CHANGED: repository/build.gradle.kts
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "com.androidbyexample.movies.repository"
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get())
targetCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get())
}
}
dependencies {
implementation(project(":data"))
implementation(libs.bundles.retrofit)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
implementation(libs.material)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
}
ADDED: repository/src/main/java/com/androidbyexample/movies/repository/MovieApiService.kt
package com.androidbyexample.movies.repository
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
interface MovieApiService {
@GET("movie")
suspend fun getMovies(): Response<List<MovieDto>>
@GET("actor")
suspend fun getActors(): Response<List<ActorDto>>
@GET("rating")
suspend fun getRatings(): Response<List<RatingDto>>
@GET("rating/{id}/movies")
suspend fun getRatingWithMovies(@Path("id") id: String): Response<RatingWithMoviesDto?>
@GET("movie/{id}/cast")
suspend fun getMovieWithCast(@Path("id") id: String): Response<MovieWithCastDto?>
@GET("actor/{id}/filmography")
suspend fun getActorWithFilmography(@Path("id") id: String): Response<ActorWithFilmographyDto?>
@GET("movie/{id}")
suspend fun getMovie(@Path("id") id: String): Response<MovieDto?>
@GET("actor/{id}")
suspend fun getActor(@Path("id") id: String): Response<ActorDto?>
@GET("rating/{id}")
suspend fun getRating(@Path("id") id: String): Response<RatingDto?>
@POST("movie")
suspend fun createMovie(@Body movie: MovieDto): Response<MovieDto>
@POST("actor")
suspend fun createActor(@Body actor: ActorDto): Response<ActorDto>
@POST("rating")
suspend fun createRating(@Body rating: RatingDto): Response<RatingDto>
@PUT("movie/{id}")
suspend fun updateMovie(
@Path("id") id: String,
@Body movie: MovieDto
): Response<Int> // number updated
@PUT("actor/{id}")
suspend fun updateActor(
@Path("id") id: String,
@Body actor: ActorDto
): Response<Int> // number updated
@PUT("rating/{id}")
suspend fun updateRating(
@Path("id") id: String,
@Body rating: RatingDto
): Response<Int> // number updated
@DELETE("rating/{id}")
suspend fun deleteRating(
@Path("id") id: String,
): Response<Int> // number deleted
@DELETE("movie/{id}")
suspend fun deleteMovie(
@Path("id") id: String,
): Response<Int> // number deleted
@DELETE("actor/{id}")
suspend fun deleteActor(
@Path("id") id: String,
): Response<Int> // number deleted
@GET("reset")
suspend fun reset(): Response<Int>
companion object {
fun create(serverBaseUrl: String): MovieApiService =
Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(serverBaseUrl)
.build()
.create(MovieApiService::class.java)
}
}
CHANGED: repository/src/main/java/com/androidbyexample/movies/repository/MovieRepository.kt
package com.androidbyexample.movies.repository
import kotlinx.coroutines.flow.Flow
interface MovieRepository {
val ratingsFlow: Flow<List<RatingDto>>
val moviesFlow: Flow<List<MovieDto>>
val actorsFlow: Flow<List<ActorDto>>
// fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto>
// fun getMovieWithCastFlow(id: String): Flow<MovieWithCastDto>
// fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmographyDto>
fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto?>
fun getMovieWithCastFlow(id: String): Flow<MovieWithCastDto?>
fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmographyDto?>
suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto
suspend fun getMovieWithCast(id: String): MovieWithCastDto
suspend fun getActorWithFilmography(id: String): ActorWithFilmographyDto
suspend fun getRating(id: String): RatingDto
suspend fun getMovie(id: String): MovieDto
suspend fun getActor(id: String): ActorDto
suspend fun insert(movie: MovieDto)
suspend fun insert(actor: ActorDto)
suspend fun insert(rating: RatingDto)
suspend fun upsert(movie: MovieDto)
suspend fun upsert(actor: ActorDto)
suspend fun upsert(rating: RatingDto)
suspend fun deleteMoviesById(ids: Set<String>)
suspend fun deleteActorsById(ids: Set<String>)
suspend fun deleteRatingsById(ids: Set<String>)
suspend fun resetDatabase()
}
ADDED: repository/src/main/java/com/androidbyexample/movies/repository/MovieRestRepository.kt
package com.androidbyexample.movies.repository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import retrofit2.Response
import kotlin.reflect.KProperty
class MovieRestRepository(
private val serverBaseUrl: String,
): MovieRepository {
// create a coroutine scope that will last for the duration of the application
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// We'll be dealing with many flows to manage data retrieved from the server.
// To make this a bit cleaner, I'm wrapping the flow and the code to fetch
// its data in a helper class I'll call a FlowManager. You don't need to do
// this; you could just track the flows separately as we have been doing.
// For example, for the movies list, we'd need
// private val _movies = MutableStateFlow<List<MovieDto>>(emptyList)
// val movies: Flow<List<MovieDto>> = _movies.asStateFlow()
// fun fetchMovies() {
// coroutineScope.launch(Dispatchers.IO) {
// _movies.value =
// movieApiService
// .getMovies()
// .takeIf { it.isSuccessful }
// ?.body()
// ?: emptyList()
// }
// }
// FlowManager is a kotlin property delegate (it provides the necessary
// getValue() function) and can be used with a "by" to automatically
// expose the Flow
// We also track all flow managers in a list so we can walk through to
// re-fetch them. We could be more efficient by using keys to only
// update flows that need it, but for this example, updaing all flows
// is sufficient
sealed interface FlowId
data object Movies: FlowId
data object Actors: FlowId
data object Ratings: FlowId
data object MovieWithCast: FlowId
data object RatingWithMovies: FlowId
data object ActorWithFilmography: FlowId
private val allFlowManagers = mutableMapOf<FlowId, FlowManager<*>>()
private fun fetchAll() =
allFlowManagers.values.forEach { it.fetch() }
fun <T> flowListManager(
id: FlowId,
fetcher: suspend () -> Response<List<T>>
) = FlowManager(id, emptyList(), fetcher)
fun <T> flowManager(
id: FlowId,
fetcher: suspend () -> Response<T?>
) = FlowManager(id, null, fetcher)
inner class FlowManager<T>(
id: FlowId,
private val defaultValue: T,
private val fetcher: suspend () -> Response<T>
) {
private val _flow = MutableStateFlow(defaultValue)
val flow: Flow<T> = _flow.asStateFlow()
init {
allFlowManagers[id] = this
fetch()
}
fun fetch() =
coroutineScope.launch {
_flow.value = fetcher().takeIf { it.isSuccessful }?.body() ?: defaultValue
}
operator fun getValue(thisRef: Any?, property: KProperty<*>): Flow<T> {
return flow
}
}
private val movieApiService = MovieApiService.create(serverBaseUrl)
override val ratingsFlow by flowListManager(Ratings) { movieApiService.getRatings() }
override val moviesFlow by flowListManager(Movies) { movieApiService.getMovies() }
override val actorsFlow by flowListManager(Actors) { movieApiService.getActors() }
// NOTE: The following assume that only one of each is active at a time
// For our app, that should be the case (the edit screens don't allow
// any deeper navigation), but if you wanted to be more general, you
// could set up a WeakHashMap where the key is the flow and the value
// is the manager (which also has a weak reference to the Flow).
// Weak references allow the object they point to be garbage collected
// when there are no remaining strong references to it, and they'll be removed
// from the map when no longer referenced. You would then walk through all the
// values in the map and call their fetch() functions.
override fun getRatingWithMoviesFlow(id: String) =
flowManager(RatingWithMovies) { movieApiService.getRatingWithMovies(id) }.flow
override fun getMovieWithCastFlow(id: String): Flow<MovieWithCastDto?> =
flowManager(MovieWithCast) { movieApiService.getMovieWithCast(id) }.flow
override fun getActorWithFilmographyFlow(id: String): Flow<ActorWithFilmographyDto?> =
flowManager(ActorWithFilmography) { movieApiService.getActorWithFilmography(id) }.flow
private suspend fun <T> getOrError(
id: String,
fetch: suspend MovieApiService.(String) -> Response<T?>
): T = withContext(Dispatchers.IO) {
movieApiService.fetch(id).takeIf {
val code = it.code() // example of how to get the HTTP status
it.isSuccessful
}?.body()
?: throw RuntimeException("$id not found")
}
override suspend fun getRatingWithMovies(id: String) =
getOrError(id) { movieApiService.getRatingWithMovies(it) }
override suspend fun getMovieWithCast(id: String) =
getOrError(id) { movieApiService.getMovieWithCast(it) }
override suspend fun getActorWithFilmography(id: String) =
getOrError(id) { movieApiService.getActorWithFilmography(it) }
override suspend fun getRating(id: String): RatingDto =
getOrError(id) { movieApiService.getRating(it) }
override suspend fun getMovie(id: String) =
getOrError(id) { movieApiService.getMovie(it) }
override suspend fun getActor(id: String): ActorDto =
getOrError(id) { movieApiService.getActor(it) }
override suspend fun insert(movie: MovieDto) {
movieApiService.createMovie(movie)
fetchAll()
}
override suspend fun insert(actor: ActorDto) {
movieApiService.createActor(actor)
fetchAll()
}
override suspend fun insert(rating: RatingDto) {
movieApiService.createRating(rating)
fetchAll()
}
override suspend fun upsert(movie: MovieDto) {
movieApiService.updateMovie(movie.id, movie)
fetchAll()
}
override suspend fun upsert(actor: ActorDto) {
movieApiService.updateActor(actor.id, actor)
fetchAll()
}
override suspend fun upsert(rating: RatingDto) {
movieApiService.updateRating(rating.id, rating)
fetchAll()
}
private suspend fun deleteById(
ids: Set<String>,
delete: suspend MovieApiService.(String) -> Unit,
) {
ids.forEach { id ->
movieApiService.delete(id)
}
fetchAll()
}
override suspend fun deleteMoviesById(ids: Set<String>) {
deleteById(ids) { deleteMovie(it) }
}
override suspend fun deleteActorsById(ids: Set<String>) {
deleteById(ids) { deleteActor(it) }
}
override suspend fun deleteRatingsById(ids: Set<String>) {
deleteById(ids) { deleteRating(it) }
}
override suspend fun resetDatabase() {
movieApiService.reset()
fetchAll()
}
companion object {
fun create(serverBaseUrl: String) =
MovieRestRepository(serverBaseUrl)
}
}