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)
}
}