Skip to content

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

@OptIn(ExperimentalMaterial3Api::class) @Composable fun DetailPlaceholder( @StringRes messageId: Int, content: @Composable () -> Unit = {}, ) { Scaffold( topBar = { val colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primary, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, titleContentColor = MaterialTheme.colorScheme.onPrimary, navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, actionIconContentColor = MaterialTheme.colorScheme.onSecondary ) TopAppBar( colors = colors, title = {}, ) } ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize(), contentAlignment = Alignment.Center ) { // Text(stringResource(id = messageId)) Column { Display(stringResource(id = messageId)) content() } } } }
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) } }