Open menu with table of contents Android Assignment 05
Logo of Stuttgart Media University for light theme Logo of Stuttgart Media University for dark theme
Android Development

Android Assignment 05

Introduction to API calls and image loading

Stuttgart Media University

Please conduct the following tasks alone. For implementation details you can refer to the lecture slides or the Android developer website. Please do not hesitate to ask me or the tutor if you have any questions.

1 Tasks

  • Copy the starter project assignmentCode/assignment5 into the assignment5 folder of your Git repository. The starter is the solution of assignment 4 plus a Retrofit client for the OMDb API and the Coil image loading library.
  • Create a free OMDb API key: https://www.omdbapi.com/apikey.aspx (you get it by e-mail). Put it into the file local.properties in the project root:
OMDB_API_KEY=your_key_here

local.properties is ignored by Git, so the key never ends up in your repository. The build reads it and makes it available as BuildConfig.OMDB_API_KEY (see app/build.gradle.kts).

  • Replace the static movie data of the search screen with a request to the OMDb search endpoint: https://www.omdbapi.com/?apikey=YOURKEY&s=Matrix, where "Matrix" is the text from the textField.
  • Show the poster of every movie in the search results and in the saved movies with Coil.
  • Implement the TODOs of the starter:
    1. network/MovieApi.kt: the two @Query parameters s and apikey of searchMovies().
    2. ui/search/SearchViewModel.kt: searchMovies() calls the API in a coroutine, shows a loading state and handles the error cases.
    3. ui/composables/MovieItem.kt: the poster with AsyncImage.
  • Run the UI test to validate your implementation.

2 What changed in the starter compared to assignment 4

  • model/Movie.kt: the entity now has the fields of the API (imdbId, title, year, poster), actor is gone. @SerializedName maps the JSON keys (imdbID, Title, ...) to the Kotlin properties, so the same class is the Room entity and the Gson target. A movie cannot be saved twice (unique index on imdbId).
  • model/MovieDatabase.kt: the schema changed, therefore the database version is 2 and fallbackToDestructiveMigration() deletes the old data instead of migrating it. Uninstall the assignment 4 app from your emulator before you start, otherwise the old database is still there.
  • network/MovieApi.kt: the Retrofit interface. network/SearchResponse.kt: the JSON object of the search endpoint.
  • MovieTrackerApplication.kt: the AppContainer additionally builds the Retrofit client and passes it to the SearchViewModel.
  • AndroidManifest.xml: the INTERNET permission.
  • ui/search/SearchViewModel.kt: SearchUiState has isLoading and errorMessage, the SearchScreen shows a progress indicator and the error text.

3 The API response

Request: https://www.omdbapi.com/?apikey=YOURKEY&s=Matrix

{
  "Search": [
    {
      "Title": "The Matrix",
      "Year": "1999",
      "imdbID": "tt0133093",
      "Type": "movie",
      "Poster": "https://m.media-amazon.com/images/M/MV5BN2NmN2VhMTQtMDNiOS00NDlhLTliMjgtODE2ZTY0ODQyNDRhXkEyXkFqcGc@._V1_SX300.jpg"
    },
    {
      "Title": "The Matrix Reloaded",
      "Year": "2003",
      "imdbID": "tt0234215",
      "Type": "movie",
      "Poster": "N/A"
    }
  ],
  "totalResults": "2",
  "Response": "True"
}

Without results the object looks like this, Search is missing:

{ "Response": "False", "Error": "Movie not found!" }

Therefore SearchResponse.search is nullable. The poster URL can be the string "N/A": Coil cannot load it and shows the error painter instead.

4 Hints

  • Retrofit: @Query("s") searchString: String adds ?s=... to the request. Give the apikey parameter the default value BuildConfig.OMDB_API_KEY, then the ViewModel only passes the search string.
  • Coroutines: searchMovies() in the API interface is a suspend function. Call it inside viewModelScope.launch { ... } and wrap it in try/catch: an IOException means no network, a retrofit2.HttpException means the server answered with an error code (401 when the key is wrong).
  • UI state: set SearchUiState(isLoading = true) before the request and the result (or the error message) afterwards. The SearchScreen already renders isLoading and errorMessage.
  • Coil: AsyncImage(model = movie.poster, contentDescription = null, error = painterResource(R.drawable.ic_movie_placeholder), modifier = Modifier.width(80.dp).height(120.dp)).
  • Test the API first: open the request URL in the browser or in an API client like Postman and look at the JSON before you write code against it.
  • See the lecture "Android Networking".

5 Screens

Search screen:

center 30%

Home screen:

center 30%

6 UI test

The test (Assignment5Test.kt) needs internet access and an empty app state (uninstall the app first). It depends on the live OMDb data: the search "Matrix" has to return "The Matrix", the search "her" has to return "Her".

package de.hdmstuttgart.movietracker

import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import org.junit.FixMethodOrder
import org.junit.Rule
import org.junit.Test
import org.junit.runners.MethodSorters

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class Assignment5Test {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    /** Waits until at least [count] movie items are shown (the data arrives asynchronously). */
    private fun waitForMovieItems(count: Int) {
        composeTestRule.waitUntil(timeoutMillis = TIMEOUT) {
            composeTestRule.onAllNodesWithTag("movieItem").fetchSemanticsNodes().size >= count
        }
    }

    /** Searches for [query] and waits until a result with the exact title [title] is shown. */
    private fun searchAndWaitFor(query: String, title: String): SemanticsNodeInteraction {
        composeTestRule.onNodeWithTag("openSearchButton").performClick()
        composeTestRule.onNodeWithTag("textField").performTextInput(query)
        composeTestRule.onNodeWithTag("searchButton").performClick()
        val matcher = hasTestTag("movieItem") and hasText(title)
        composeTestRule.waitUntil(timeoutMillis = TIMEOUT) {
            composeTestRule.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
        }
        return composeTestRule.onAllNodes(matcher)[0]
    }

    @Test
    fun test1_searchAndSaveTheMatrix() {
        searchAndWaitFor("Matrix", "The Matrix").performClick()
        waitForMovieItems(1)
        composeTestRule.onAllNodesWithTag("movieItem")[0].assertTextContains("The Matrix")
    }

    @Test
    fun test2_searchAndSaveHer() {
        waitForMovieItems(1)
        composeTestRule.onAllNodesWithTag("movieItem")[0].assertTextContains("The Matrix")
        searchAndWaitFor("her", "Her").performClick()
        waitForMovieItems(2)
    }

    @Test
    fun test3_savedMoviesAreShownInOrder() {
        waitForMovieItems(2)
        composeTestRule.onAllNodesWithTag("movieItem")[0].assertTextContains("The Matrix")
        composeTestRule.onAllNodesWithTag("movieItem")[1].assertTextContains("Her")
    }

    companion object {
        private const val TIMEOUT = 10_000L
    }
}

7 Gradle dependencies

Retrofit, Gson and Coil are already configured in the starter. These are the relevant lines:

gradle/libs.versions.toml:

[versions]
retrofit = "3.0.0"
gson = "2.14.0"
coil = "3.5.0"

[libraries]
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }

Module app/build.gradle.kts:

// Reads OMDB_API_KEY from local.properties
val localProperties = Properties().apply {
    val file = rootProject.file("local.properties")
    if (file.exists()) file.inputStream().use { load(it) }
}
val omdbApiKey: String = localProperties.getProperty("OMDB_API_KEY") ?: ""

android {
    defaultConfig {
        // ...
        buildConfigField("String", "OMDB_API_KEY", "\"$omdbApiKey\"")
    }
    buildFeatures {
        compose = true
        buildConfig = true
    }
}

dependencies {
    implementation(libs.retrofit)
    implementation(libs.retrofit.converter.gson)
    implementation(libs.gson)
    implementation(libs.coil.compose)
    implementation(libs.coil.network.okhttp)   // Coil needs a network client to load URLs
    // ...
}

Submission

  • Push the project to the assignment5 folder of your Git repository (assignment5/app/src/main/...).
  • Never commit your API key. local.properties is in .gitignore, keep it there.
  • For grading, your Assignment5Test.kt is replaced by the reference version and executed on a fresh emulator with the lecturer's API key in local.properties.