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]
// ...
jersey="3.1.9"
activation="2.1.3"
retrofit = "2.9.0"

[libraries]
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-json = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
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" }
// ...

[bundles]
retrofit = [
    "retrofit",
    "retrofit-json"
]

server = [
    // ...
[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.core.ktx)
    implementation(libs.androidx.appcompat)
    // ...
}

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/compose/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?>

    @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/compose/movies/repository/MovieRestRepository.kt
// ...
import retrofit2.Response

class MovieRestRepository(
    private val serverBaseUrl: String,
): MovieRepository {
    private lateinit var coroutineScope: CoroutineScope
    private val allFlowManagers = mutableListOf<FlowManager<*>>()

    inner class FlowManager<T>(
        private val defaultValue: T,
        private val fetcher: suspend () -> Response<T>
    ) {
        private val _flow = MutableStateFlow(defaultValue)
        init {
            allFlowManagers.add(this)
        }
        val flow: Flow<T>
            get() = _flow

        fun fetch() =
            coroutineScope.launch(Dispatchers.IO) {
                _flow.value = fetcher().takeIf { it.isSuccessful }?.body() ?: defaultValue
            }
    }

    override fun initialize(coroutineScope: CoroutineScope) {
        this.coroutineScope = coroutineScope
        allFlowManagers.forEach { it.fetch() }
    }

    private val movieApiService = MovieApiService.create(serverBaseUrl)

    private val moviesFlowManager = FlowManager(emptyList()) { movieApiService.getMovies() }
    private val actorsFlowManager = FlowManager(emptyList()) { movieApiService.getActors() }
    private val ratingsFlowManager = FlowManager(emptyList()) { movieApiService.getRatings() }

    override val ratingsFlow: Flow<List<RatingDto>> = ratingsFlowManager.flow
    override val moviesFlow: Flow<List<MovieDto>> = moviesFlowManager.flow
    override val actorsFlow: Flow<List<ActorDto>> = actorsFlowManager.flow

    // 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 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.

    private var ratingWithMoviesFlowManager: FlowManager<RatingWithMoviesDto?>? = null
    // not implementing the ActorWithFilmographyDto and MovieWithCastDto versions
    //   but they would be similar
    override fun getRatingWithMoviesFlow(id: String) =
        FlowManager(null) {
            movieApiService.getRatingWithMovies(id)
        }
            .apply { fetch() } // start fetching
            .let {
                ratingWithMoviesFlowManager = it
                it.flow // return the 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) { getRatingWithMovies(it) }

    override suspend fun getMovieWithCast(id: String) =
        getOrError(id) { getMovieWithCast(it) }

    override suspend fun getActorWithFilmography(id: String) =
        getOrError(id) { getActorWithFilmography(it) }

    override suspend fun getMovie(id: String) =
        getOrError(id) { getMovie(it) }

    override suspend fun insert(movie: MovieDto) {
        movieApiService.createMovie(movie)
        moviesFlowManager.fetch()
        ratingWithMoviesFlowManager?.fetch()
    }

    override suspend fun insert(actor: ActorDto) {
        movieApiService.createActor(actor)
        actorsFlowManager.fetch()
    }

    override suspend fun insert(rating: RatingDto) {
        movieApiService.createRating(rating)
        ratingsFlowManager.fetch()
    }

    override suspend fun upsert(movie: MovieDto) {
        movieApiService.updateMovie(movie.id, movie)
        moviesFlowManager.fetch()
        ratingWithMoviesFlowManager?.fetch()
    }

    override suspend fun upsert(actor: ActorDto) {
        movieApiService.updateActor(actor.id, actor)
        actorsFlowManager.fetch()
    }

    override suspend fun upsert(rating: RatingDto) {
        movieApiService.updateRating(rating.id, rating)
        ratingsFlowManager.fetch()
    }

    private suspend fun deleteById(
        ids: Set<String>,
        delete: suspend MovieApiService.(String) -> Unit,
        vararg flowManagersToFetch: FlowManager<*>?,
    ) {
        ids.forEach { id ->
            movieApiService.delete(id)
        }
        flowManagersToFetch.forEach { it?.fetch() }
    }
    override suspend fun deleteMoviesById(ids: Set<String>) {
        deleteById(ids, { deleteMovie(it) }, moviesFlowManager, ratingWithMoviesFlowManager)
    }
    override suspend fun deleteActorsById(ids: Set<String>) {
        deleteById(ids, { deleteActor(it) }, actorsFlowManager)
    }
    override suspend fun deleteRatingsById(ids: Set<String>) {
        deleteById(ids, { deleteRating(it) }, ratingsFlowManager, moviesFlowManager)
    }


    override suspend fun resetDatabase() {
        movieApiService.reset()
        actorsFlowManager.fetch()
        moviesFlowManager.fetch()
        ratingsFlowManager.fetch()
    }

    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/compose/movies/repository/MovieRepository.kt
// ...
interface MovieRepository {
    // ...
    val actorsFlow: Flow<List<ActorDto>>

//  fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto>
    fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto?>

    suspend fun getMovie(id: String): MovieDto
    // ...
}

The MovieRestRepository requires a CoroutineScope to perform its work. We want to use the viewModelScope, but we have a chicken-and-egg problem. We're passing the repository as a parameter to the view model, which means we don't have a view model when we create the repository, therefore we don't have the viewModelScope.

To get around this, we'll add a new initialize() function

show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieRepository.kt
// ...

interface MovieRepository {
    fun initialize(coroutineScope: CoroutineScope)

    val ratingsFlow: Flow<List<RatingDto>>
    // ...
}

that the view model can call to set the coroutine scope

show in full file app/src/main/java/com/androidbyexample/compose/movies/MovieViewModel.kt
// ...

@OptIn(FlowPreview::class)
class MovieViewModel(
    // ...
): AndroidViewModel(application), MovieRepository by repository {
    // ...
    private val ratingUpdateFlow = MutableStateFlow<RatingDto?>(null)
    init {
        if (repository is MovieRestRepository) {
            repository.initialize(viewModelScope)
        }

        viewModelScope.launch {
            // ...
    }
    // ...
}

This means that we also have to tweak the existing MovieDatabaseRepository

show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieDatabaseRepository.kt
// ...
class MovieDatabaseRepository(
    private val dao: MovieDao
): MovieRepository {
    override fun initialize(coroutineScope: CoroutineScope) {
        // nothing to do
    }

    override val ratingsFlow =
        // ...
}

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"
    xmlns:tools="http://schemas.android.com/tools">
    // ...
    <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:theme="@style/Theme.Movies"
        android:usesCleartextTraffic="true"
        tools:targetApi="31">
        <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
// ...
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />

    <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.


All code changes

CHANGED: app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<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:theme="@style/Theme.Movies" android:usesCleartextTraffic="true" tools:targetApi="31">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleInstance" android:theme="@style/Theme.Movies"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <receiver android:name=".glance.MovieAppWidgetReceiver" android:exported="true"> <intent-filter> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> </intent-filter> <meta-data android:name="android.appwidget.provider" android:resource="@xml/movies_app_widget_info" /> </receiver> </application> </manifest>
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/MovieViewModel.kt
package com.androidbyexample.compose.movies

import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.glance.appwidget.updateAll
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.androidbyexample.compose.movies.glance.MovieAppWidget
import com.androidbyexample.compose.movies.repository.ActorDto
import com.androidbyexample.compose.movies.repository.MovieDatabaseRepository
import com.androidbyexample.compose.movies.repository.MovieDto
import com.androidbyexample.compose.movies.repository.MovieRepository
import com.androidbyexample.compose.movies.repository.MovieRestRepository
import com.androidbyexample.compose.movies.repository.RatingDto
import com.androidbyexample.compose.movies.screens.MovieList
import com.androidbyexample.compose.movies.screens.Screen
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch

@OptIn(FlowPreview::class)
class MovieViewModel(
    application: Application,
    private val repository: MovieRepository,
): AndroidViewModel(application), MovieRepository by repository {
    private val _selectedIdsFlow = MutableStateFlow<Set<String>>(emptySet())
    val selectedIdsFlow: Flow<Set<String>> = _selectedIdsFlow.asStateFlow()

    fun clearSelectedIds() {
        _selectedIdsFlow.value = emptySet()
    }
    fun toggleSelection(id: String) {
        if (id in _selectedIdsFlow.value) {
            _selectedIdsFlow.value -= id
        } else {
            _selectedIdsFlow.value += id
        }
    }

    private var screenStack = listOf<Screen>(MovieList)
        set(value) {
            field = value
            clearSelectedIds()
            currentScreen = value.lastOrNull()
        }

    // NOTE: We're keep this as a Compose State for comparison.
    //       You can use Compose state to expose anything from the view model,
    //       but our example will be using Flow from now on to demonstrate how
    //       the view model can be used without Compose, perhaps for other
    //       platforms such as iOS, desktop, web or command line
    var currentScreen by mutableStateOf<Screen?>(MovieList)
        private set

    fun pushScreen(screen: Screen) {
        screenStack = screenStack + screen
    }
    fun popScreen() {
        screenStack = screenStack.dropLast(1)
    }

    fun setScreen(screen: Screen) {
        screenStack = listOf(screen)
    }

    fun setScreens(vararg screens: Screen) {
        screenStack = screens.toList()
    }

    fun update(movie: MovieDto) {
        movieUpdateFlow.value = movie
    }
    fun update(actor: ActorDto) {
        actorUpdateFlow.value = actor
    }
    fun update(rating: RatingDto) {
        ratingUpdateFlow.value = rating
    }
    // using a debounced flow as a person-update queue
    private val movieUpdateFlow = MutableStateFlow<MovieDto?>(null)
    private val actorUpdateFlow = MutableStateFlow<ActorDto?>(null)
    private val ratingUpdateFlow = MutableStateFlow<RatingDto?>(null)
    init {
if (repository is MovieRestRepository) { repository.initialize(viewModelScope) }
viewModelScope.launch { repository.moviesFlow.collectLatest { MovieAppWidget().updateAll(getApplication()) } } viewModelScope.launch { movieUpdateFlow.debounce(500).collect { movie -> movie?.let { repository.upsert(it) } } } viewModelScope.launch { actorUpdateFlow.debounce(500).collect { actor -> actor?.let { repository.upsert(it) } } } viewModelScope.launch { ratingUpdateFlow.debounce(500).collect { rating -> rating?.let { repository.upsert(it) } } } } fun deleteSelectedMovies() { viewModelScope.launch { deleteMoviesById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } fun deleteSelectedActors() { viewModelScope.launch { deleteActorsById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } fun deleteSelectedRatings() { viewModelScope.launch { deleteRatingsById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } companion object { val Factory: ViewModelProvider.Factory = object: ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create( modelClass: Class<T>, extras: CreationExtras ): T { // Get the Application object from extras // val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) val application = checkNotNull( extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] ) return MovieViewModel( application, // MovieDatabaseRepository.create(application) MovieRestRepository.create(getServerAddress()) ) as T } } } }
CHANGED: gradle/libs.versions.toml
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
appcompat = "1.7.0"
material = "1.12.0"
room = "2.6.1"
ksp = "2.0.21-1.0.28"
compileSdk = "35"
targetSdk = "35"
minSdk = "24"
jvmTarget = "11"
javaVersion = "VERSION_11"
lifecycle-compose = "2.8.7"
icons-extended = "1.7.6"
glance="1.1.1"
jetbrainsKotlinJvm = "2.0.21"
jersey="3.1.9" activation="2.1.3"
retrofit = "2.9.0" [libraries] retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-json = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
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" }
glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } icons-extended = { group = "androidx.compose.material", name = "material-icons-extended-android", version.ref = "icons-extended"} lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" } 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-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } 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" }
[bundles] retrofit = [ "retrofit", "retrofit-json" ]
server = [ "jersey-grizzly2", "jersey-jetty", "jersey-servlet", "jersey-jackson", "jersey-server", "jersey-hk2", "activation" ]
[plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 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)
    alias(libs.plugins.kotlin.android)
}

android {
    namespace = "com.androidbyexample.compose.movies.repository"
    compileSdk = libs.versions.compileSdk.get().toInt()

    defaultConfig {
        minSdk = libs.versions.minSdk.get().toInt()

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles("consumer-rules.pro")
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get())
        targetCompatibility = JavaVersion.valueOf(libs.versions.javaVersion.get())
    }
    kotlinOptions {
        jvmTarget = libs.versions.jvmTarget.get()
    }
}

dependencies {
    implementation(project(":data"))
// implementation(libs.bundles.retrofit)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) }
ADDED: repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieApiService.kt
package com.androidbyexample.compose.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?> @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/compose/movies/repository/MovieDatabaseRepository.kt
package com.androidbyexample.compose.movies.repository

import android.content.Context
import com.androidbyexample.compose.movies.data.MovieDao
import com.androidbyexample.compose.movies.data.createDao
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

class MovieDatabaseRepository(
    private val dao: MovieDao
): MovieRepository {
override fun initialize(coroutineScope: CoroutineScope) { // nothing to do }
override val ratingsFlow = dao.getRatingsFlow() .map { ratings ->// for each List<RatingEntity> that's emitted // create a list of RatingDto ratings.map { rating -> rating.toDto() } // map each entity to Dto } override val moviesFlow = dao.getMoviesFlow() .map { movies -> movies.map { it.toDto() } } override val actorsFlow = dao.getActorsFlow() .map { actors -> actors.map { it.toDto() } } override fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto> = dao.getRatingWithMoviesFlow(id) .map { it.toDto() } override suspend fun getMovie(id: String): MovieDto = dao.getMovie(id).toDto() override suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto = dao.getRatingWithMovies(id).toDto() override suspend fun getMovieWithCast(id: String): MovieWithCastDto = dao.getMovieWithCast(id).toDto() override suspend fun getActorWithFilmography(id: String): ActorWithFilmographyDto = dao.getActorWithFilmography(id).toDto() override suspend fun insert(movie: MovieDto) = dao.insert(movie.toEntity()) override suspend fun insert(actor: ActorDto) = dao.insert(actor.toEntity()) override suspend fun insert(rating: RatingDto) = dao.insert(rating.toEntity()) override suspend fun upsert(movie: MovieDto) = dao.upsert(movie.toEntity()) override suspend fun upsert(actor: ActorDto) = dao.upsert(actor.toEntity()) override suspend fun upsert(rating: RatingDto) = dao.upsert(rating.toEntity()) override suspend fun deleteMoviesById(ids: Set<String>) = dao.deleteMoviesById(ids) override suspend fun deleteActorsById(ids: Set<String>) = dao.deleteActorsById(ids) override suspend fun deleteRatingsById(ids: Set<String>) = dao.deleteRatingsById(ids) override suspend fun resetDatabase() = dao.resetDatabase() companion object { fun create(context: Context) = MovieDatabaseRepository(createDao(context)) } }
CHANGED: repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieRepository.kt
package com.androidbyexample.compose.movies.repository

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow

interface MovieRepository {
fun initialize(coroutineScope: CoroutineScope)
val ratingsFlow: Flow<List<RatingDto>> val moviesFlow: Flow<List<MovieDto>> val actorsFlow: Flow<List<ActorDto>>
// fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto> fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto?>
suspend fun getMovie(id: String): MovieDto suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto suspend fun getMovieWithCast(id: String): MovieWithCastDto suspend fun getActorWithFilmography(id: String): ActorWithFilmographyDto 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/compose/movies/repository/MovieRestRepository.kt
package com.androidbyexample.compose.movies.repository

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import retrofit2.Response

class MovieRestRepository( private val serverBaseUrl: String, ): MovieRepository { private lateinit var coroutineScope: CoroutineScope private val allFlowManagers = mutableListOf<FlowManager<*>>() inner class FlowManager<T>( private val defaultValue: T, private val fetcher: suspend () -> Response<T> ) { private val _flow = MutableStateFlow(defaultValue) init { allFlowManagers.add(this) } val flow: Flow<T> get() = _flow fun fetch() = coroutineScope.launch(Dispatchers.IO) { _flow.value = fetcher().takeIf { it.isSuccessful }?.body() ?: defaultValue } } override fun initialize(coroutineScope: CoroutineScope) { this.coroutineScope = coroutineScope allFlowManagers.forEach { it.fetch() } } private val movieApiService = MovieApiService.create(serverBaseUrl) private val moviesFlowManager = FlowManager(emptyList()) { movieApiService.getMovies() } private val actorsFlowManager = FlowManager(emptyList()) { movieApiService.getActors() } private val ratingsFlowManager = FlowManager(emptyList()) { movieApiService.getRatings() } override val ratingsFlow: Flow<List<RatingDto>> = ratingsFlowManager.flow override val moviesFlow: Flow<List<MovieDto>> = moviesFlowManager.flow override val actorsFlow: Flow<List<ActorDto>> = actorsFlowManager.flow // 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 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. private var ratingWithMoviesFlowManager: FlowManager<RatingWithMoviesDto?>? = null // not implementing the ActorWithFilmographyDto and MovieWithCastDto versions // but they would be similar override fun getRatingWithMoviesFlow(id: String) = FlowManager(null) { movieApiService.getRatingWithMovies(id) } .apply { fetch() } // start fetching .let { ratingWithMoviesFlowManager = it it.flow // return the 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) { getRatingWithMovies(it) } override suspend fun getMovieWithCast(id: String) = getOrError(id) { getMovieWithCast(it) } override suspend fun getActorWithFilmography(id: String) = getOrError(id) { getActorWithFilmography(it) } override suspend fun getMovie(id: String) = getOrError(id) { getMovie(it) } override suspend fun insert(movie: MovieDto) { movieApiService.createMovie(movie) moviesFlowManager.fetch() ratingWithMoviesFlowManager?.fetch() } override suspend fun insert(actor: ActorDto) { movieApiService.createActor(actor) actorsFlowManager.fetch() } override suspend fun insert(rating: RatingDto) { movieApiService.createRating(rating) ratingsFlowManager.fetch() } override suspend fun upsert(movie: MovieDto) { movieApiService.updateMovie(movie.id, movie) moviesFlowManager.fetch() ratingWithMoviesFlowManager?.fetch() } override suspend fun upsert(actor: ActorDto) { movieApiService.updateActor(actor.id, actor) actorsFlowManager.fetch() } override suspend fun upsert(rating: RatingDto) { movieApiService.updateRating(rating.id, rating) ratingsFlowManager.fetch() } private suspend fun deleteById( ids: Set<String>, delete: suspend MovieApiService.(String) -> Unit, vararg flowManagersToFetch: FlowManager<*>?, ) { ids.forEach { id -> movieApiService.delete(id) } flowManagersToFetch.forEach { it?.fetch() } } override suspend fun deleteMoviesById(ids: Set<String>) { deleteById(ids, { deleteMovie(it) }, moviesFlowManager, ratingWithMoviesFlowManager) } override suspend fun deleteActorsById(ids: Set<String>) { deleteById(ids, { deleteActor(it) }, actorsFlowManager) } override suspend fun deleteRatingsById(ids: Set<String>) { deleteById(ids, { deleteRating(it) }, ratingsFlowManager, moviesFlowManager) } override suspend fun resetDatabase() { movieApiService.reset() actorsFlowManager.fetch() moviesFlowManager.fetch() ratingsFlowManager.fetch() } companion object { fun create(serverBaseUrl: String) = MovieRestRepository(serverBaseUrl) } }