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

Android Networking

Stuttgart Media University

1 Agenda

  • REST API Basics
  • JSON
  • REST API Clients
  • Retrofit and Gson
  • Retrofit Example in 5 Steps
  • Keeping Secrets out of Git
  • Debugging Network Calls
  • Image Loading with Coil
  • Assignment 5

2 REST API Basics

  • Representational State Transfer: an architectural style for web APIs
  • HTTP based: resources are addressed by URLs, operations are the HTTP methods GET, POST, PUT, PATCH, DELETE (plus HEAD, OPTIONS)
  • Payload is a textual representation of the resource, today almost always JSON (sometimes XML)
  • Communication between client and server is stateless: every request carries everything the server needs (parameters, authentication)
  • Example used in this lecture and assignment 5: the OMDb API, GET https://www.omdbapi.com/?apikey=YOURKEY&s=Matrix

3 Relationship between URI and HTTP Methods

HTTP method Collection resource, e.g. https://api.example.com/movies/ Member resource, e.g. https://api.example.com/movies/tt0133093
GET List the members of the collection Retrieve a representation of the member
POST Create a new member; the server assigns the URI and returns it in Location Rarely used; creates a sub-resource
PUT Replace the whole collection Replace the member, or create it if it does not exist
PATCH Update parts of the collection Update parts of the member
DELETE Delete the whole collection Delete the member

Source: Wikipedia: REST

4 HTTP Response Status Codes

  • Tell the client what happened with a request
  • 100–199 Informational responses
  • 200–299 Successful responses (200 OK, 201 Created, 204 No Content)
  • 300–399 Redirects (301 Moved Permanently, 304 Not Modified)
  • 400–499 Client errors (400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 429 Too Many Requests)
  • 500–599 Server errors (500 Internal Server Error, 503 Service Unavailable)
  • Beware: some APIs answer 200 OK and put the error into the JSON body. OMDb does exactly that for "Movie not found!"

5 JSON Basics

  • JavaScript Object Notation
  • Human readable text format, language independent
  • The standard data format of web APIs ("replacement of XML")
  • Media type: application/json
  • Data types: object ({ "key": value }), array ([ ... ]), string, number, boolean, null
  • Keys are always strings; values can be nested arbitrarily
  • Examples: restfulapi.net/json-data-types

6 JSON Example: OMDb Search 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"
}
  • An object with an array of objects; note that totalResults is a string, not a number
  • "Poster" can be the string "N/A" instead of a URL

7 JSON Example: The Error Case

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

{
  "Response": "False",
  "Error": "Movie not found!"
}
  • HTTP status is still 200 OK, the Search array is simply missing
  • Your Kotlin model must allow that: val search: List<Movie>? (nullable), otherwise the app crashes with a NullPointerException on the first empty result
  • A wrong API key is answered with HTTP 401 Unauthorized and { "Response": "False", "Error": "Invalid API key!" }
  • Always look at the real responses of an API, including the error cases, before you write code against it

8 REST API Clients

  • Best practice: do not start implementing against an API before you understand it
  • Testing and debugging API calls on Android is slow (emulator, Logcat, threading, permissions)
  • Use an API client to explore the API first:
    • Browser: fine for GET, add an extension like JSON Formatter to make the output readable
    • Postman, Insomnia, Bruno (open source, offline, collections stored as files in your repo)
    • curl -s "https://www.omdbapi.com/?apikey=YOURKEY&s=Matrix" | jq on the command line
  • Save the requests you tried as a collection; they become documentation and regression tests
  • Broken or unexpected JSON will break your client, so check the edge cases (empty result, error, "N/A")

9 Retrofit

github.com/square/retrofit

  • Turns an HTTP API into a Kotlin interface: you declare the endpoints with annotations, Retrofit generates the implementation at runtime
  • Built on OkHttp, Square's HTTP client (connection pooling, HTTP/2, caching, interceptors). Retrofit 3.0 bundles OkHttp 4.12
  • Requires Java 8+ or Android API 21+ (we have minSdk 26)
  • suspend functions are supported out of the box: no callbacks, no threading code, main-safe
  • Converters turn request and response bodies into objects: Gson, Moshi, kotlinx.serialization, Jackson, Scalars (plain strings), ...
  • Annotations for everything in a request: @GET, @POST, @Path, @Query, @Body, @Header
  • Apache License 2.0; allowed in the project

10 Gson

github.com/google/gson

  • Google's JSON library for the JVM: toJson() and fromJson() convert between objects and JSON text
  • Works by reflection: property names map to JSON keys, @SerializedName("Title") when they differ
  • No annotations or code generation required, works with classes you do not own
  • Handles generics (List<Movie>) and nested objects
  • Missing JSON keys become null. Gson does not know Kotlin's null-safety, so declare optional fields as nullable types (List<Movie>?) or you get a NullPointerException later
  • With Retrofit: retrofit2.converter.gson.GsonConverterFactory

Alternatives: kotlinx.serialization (Kotlin compiler plugin, null-safe, multiplatform) and Moshi (Square, Kotlin code generation). Both are not allowed in the project, Gson is.

11 Retrofit Example, Step 1: Dependencies and Permission

gradle/libs.versions.toml:

[versions]
retrofit = "3.0.0"
gson = "2.14.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" }

app/build.gradle.kts:

dependencies {
    implementation(libs.retrofit)
    implementation(libs.retrofit.converter.gson)
    implementation(libs.gson)
}

AndroidManifest.xml, outside of <application>:

<!-- Needed to call the OMDb API and to load the poster images -->
<uses-permission android:name="android.permission.INTERNET" />

Without it every request fails with SocketException: Permission denied. It is a normal permission, granted at install time, no runtime dialog.

12 Step 2: Data Classes for the JSON

// model/Movie.kt: one item of the "Search" array, and at the same time the Room entity
@Entity(indices = [Index(value = ["imdbId"], unique = true)])
data class Movie(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    @SerializedName("imdbID") val imdbId: String,
    @SerializedName("Title") val title: String,
    @SerializedName("Year") val year: String,
    @SerializedName("Poster") val poster: String
)

// network/SearchResponse.kt: the whole response object
data class SearchResponse(
    // Missing in the JSON when there is no result, therefore nullable
    @SerializedName("Search") val search: List<Movie>?,
    @SerializedName("totalResults") val totalResults: String?,
    @SerializedName("Response") val response: String,
    @SerializedName("Error") val error: String?
)
  • @SerializedName maps the capitalised JSON keys to Kotlin naming conventions
  • Gson ignores JSON keys without a property ("Type"), and the id is not in the JSON, so it keeps its default 0
  • One class for network and database keeps assignment 5 small; larger apps separate DTOs and entities

13 Step 3: The API Interface

// network/MovieApi.kt
/**
 * Retrofit turns this interface into an HTTP client. Example request:
 * https://www.omdbapi.com/?s=Matrix&apikey=YOURKEY
 */
interface MovieApi {

    @GET("/")
    suspend fun searchMovies(
        @Query("s") searchString: String,
        // The key comes from local.properties, see app/build.gradle.kts
        @Query("apikey") apiKey: String = BuildConfig.OMDB_API_KEY
    ): SearchResponse

    companion object {
        const val BASE_URL = "https://www.omdbapi.com/"
    }
}
  • @GET("/"): the path relative to the base URL; OMDb has a single endpoint
  • @Query("s") appends ?s=Matrix (URL-encoded), several @Query parameters are joined with &
  • The return type is the converted body. suspend makes Retrofit run the request on its own background thread and resume the caller with the result
  • An HTTP error status (401, 404, 500) is thrown as retrofit2.HttpException, a network problem as java.io.IOException

14 Step 4: Building the Client

// MovieTrackerApplication.kt
interface AppContainer {
    val movieRepository: MovieRepository
    val movieApi: MovieApi
}

class AppDataContainer(private val context: Context) : AppContainer {

    override val movieRepository: MovieRepository by lazy {
        OfflineMovieRepository(MovieDatabase.getDatabase(context).movieDao())
    }

    override val movieApi: MovieApi by lazy {
        Retrofit.Builder()
            .baseUrl(MovieApi.BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(MovieApi::class.java)
    }
}
  • Building a Retrofit instance is expensive (OkHttp client, thread pool): create it once per app, in the AppContainer, like the database
  • create(MovieApi::class.java) returns the generated implementation of the interface
  • The AppViewModelProvider.Factory passes container.movieApi to the SearchViewModel

15 Step 5: Calling the API from the ViewModel

// ui/search/SearchViewModel.kt
class SearchViewModel(
    private val movieRepository: MovieRepository,
    private val movieApi: MovieApi
) : ViewModel() {

    var searchUiState by mutableStateOf(SearchUiState())
        private set

    fun searchMovies(searchString: String) {
        viewModelScope.launch {
            searchUiState = SearchUiState(isLoading = true)
            searchUiState = try {
                // Network call: Retrofit runs it on a background thread, we only suspend here
                val response = movieApi.searchMovies(searchString)
                SearchUiState(movieList = response.search ?: emptyList(), errorMessage = response.error)
            } catch (e: IOException) {
                SearchUiState(errorMessage = e.message ?: "Network error")   // offline, timeout
            } catch (e: HttpException) {
                SearchUiState(errorMessage = "HTTP ${e.code()} ${e.message()}") // e.g. 401 wrong key
            }
        }
    }
}

data class SearchUiState(
    val movieList: List<Movie> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

The SearchScreen shows a LinearProgressIndicator while isLoading and the errorMessage as red text.

16 Keep Secrets out of Git

An API key in the source code ends up in GitLab, in every clone and in the APK. Read it from a file that Git ignores and let the build generate a constant:

// app/build.gradle.kts
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 {
        // Exposes the key to the app as BuildConfig.OMDB_API_KEY
        buildConfigField("String", "OMDB_API_KEY", "\"$omdbApiKey\"")
    }
    buildFeatures {
        buildConfig = true
    }
}
  • local.properties in the project root: OMDB_API_KEY=your_key_here. It is in .gitignore (/local.properties) because it also holds the local SDK path
  • The code uses BuildConfig.OMDB_API_KEY, see MovieApi
  • If a key was pushed anyway: revoke it and create a new one. Deleting the commit does not help, the key stays in the Git history and in every clone

17 Debugging Network Calls

  • Try the exact URL in the browser or in curl first. Compare it with the request your app sends
  • OkHttp logging interceptor prints request and response (headers, body) to Logcat. Artifact com.squareup.okhttp3:logging-interceptor (same version as Retrofit's OkHttp, 4.12.0). Only in debug builds: level BODY logs the API key
val client = OkHttpClient.Builder()
    .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY })
    .build()

Retrofit.Builder()
    .baseUrl(MovieApi.BASE_URL)
    .client(client)                                    // only in debug builds
    .addConverterFactory(GsonConverterFactory.create())
    .build()
  • Android Studio > App Inspection > Network Inspector: every request with status, headers, body and timing
  • Typical OMDb answers: 401 missing or invalid API key (also when the free key's daily limit is reached), 404 wrong base URL or path, 429 is what most other APIs send when you call them too often
  • http:// is blocked by default since API 28 (CLEARTEXT communication not permitted): use https://
  • Response: "False" comes with status 200: check response.error, not only the status code

18 Image Loading with Coil

  • Coil ("Coroutine Image Loader"): Kotlin-first image loading for Android and Compose Multiplatform, built on coroutines and Okio
  • Downloads, decodes, downsamples to the target size, caches and displays an image: one composable call
  • Cancels the request automatically when the composable leaves the composition. Essential in a LazyColumn, where items scroll in and out while their posters are still loading
  • Placeholder, error and fallback painters, success and error callbacks, transformations (rounded corners, circle crop), crossfade animation
  • Alternatives: Glide (Google), Picasso (Square, maintenance mode). Coil is the one allowed in the project

19 Coil Gradle Dependencies

gradle/libs.versions.toml:

[versions]
coil = "3.5.0"

[libraries]
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" }

app/build.gradle.kts:

dependencies {
    implementation(libs.coil.compose)
    implementation(libs.coil.network.okhttp)   // Coil needs a network client to load URLs
}
  • Coil 3 is multiplatform and ships no network stack of its own. Without coil-network-okhttp (or coil-network-ktor3) every URL fails silently and only the error painter is shown
  • coil-network-okhttp reuses OkHttp, which Retrofit already brings into the app
  • Import coil3.compose.AsyncImage. Coil 2 used coil.compose.AsyncImage, old tutorials still show that

20 Coil Basic Usage

// ui/composables/MovieItem.kt
@Composable
fun MovieItem(movie: Movie, onMovieClick: (Movie) -> Unit, modifier: Modifier = Modifier) {
    Column(modifier = modifier.fillMaxWidth().testTag("movieItem").clickable { onMovieClick(movie) }) {
        Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
            // Coil loads the poster in the background and caches it. OMDb returns "N/A" when there
            // is no poster, the error painter is shown in that case.
            AsyncImage(
                model = movie.poster,
                contentDescription = null,
                contentScale = ContentScale.Crop,
                error = painterResource(R.drawable.ic_movie_placeholder),
                modifier = Modifier
                    .width(80.dp)
                    .height(120.dp)
            )
            Spacer(modifier = Modifier.width(16.dp))
            Column {
                Text(text = movie.title, style = MaterialTheme.typography.titleMedium)
                Text(text = movie.year, style = MaterialTheme.typography.bodyMedium)
            }
        }
        HorizontalDivider()
    }
}
  • model accepts a URL string, a Uri, a File, a drawable resource id or an ImageRequest
  • Give the image a fixed size, otherwise the list jumps when the posters arrive

21 Coil: Loading, Error and Success States

// painters: placeholder while loading, error when the URL fails ("N/A", 404, offline)
AsyncImage(
    model = movie.poster,
    contentDescription = null,
    placeholder = painterResource(R.drawable.ic_movie_placeholder),
    error = painterResource(R.drawable.ic_movie_placeholder),
    onError = { state -> Log.w("Coil", "No poster for ${movie.title}", state.result.throwable) },
    modifier = Modifier.width(80.dp).height(120.dp)
)

// composables instead of painters, e.g. a progress indicator while loading
SubcomposeAsyncImage(
    model = movie.poster,
    contentDescription = null,
    loading = { CircularProgressIndicator() },
    error = { Image(painterResource(R.drawable.ic_movie_placeholder), contentDescription = null) },
    modifier = Modifier.width(80.dp).height(120.dp)
)
  • onLoading, onSuccess, onError callbacks or a single onState with the AsyncImagePainter.State
  • SubcomposeAsyncImage is more flexible but slower (subcomposition); prefer painters in long lists
  • fallback painter: shown when model itself is null

22 Coil: Caching

  • Memory cache: decoded bitmaps, keyed by URL and target size, LRU with a size relative to the app's memory. Scrolling back to a poster shows it instantly
  • Disk cache: the downloaded bytes in the app's cache directory, survives process restarts; HTTP cache headers are respected through OkHttp
  • Both are on by default and shared through one ImageLoader singleton. Opt out per request with ImageRequest.Builder(context).data(url).memoryCachePolicy(CachePolicy.DISABLED).build(), configure globally by implementing SingletonImageLoader.Factory in the Application

23 Assignment 5

Assignment 5: Introduction to API calls and image loading

What you need from this lecture for it:

  • Your OMDb key goes into local.properties (OMDB_API_KEY=...), the build turns it into BuildConfig.OMDB_API_KEY. Never commit it
  • MovieApi.searchMovies(): the two @Query parameters s and apikey, the key as default value
  • SearchViewModel.searchMovies(): viewModelScope.launch, isLoading before the call, try/catch for IOException and HttpException, response.search ?: emptyList() and response.error
  • MovieItem: AsyncImage(model = movie.poster, error = painterResource(...)) so that "N/A" posters show the placeholder; coil-network-okhttp and the INTERNET permission are already in the starter

24 Summary

  • REST APIs expose resources via URLs and HTTP methods and answer with status codes and JSON
  • Explore an API with the browser, Postman / Bruno / Insomnia or curl before writing code, including the error cases
  • Retrofit turns a Kotlin interface into an HTTP client, Gson converts JSON to data classes; nullable properties for keys that may be missing
  • Build the Retrofit instance once in the AppContainer, call suspend API functions from viewModelScope and catch IOException and HttpException
  • Secrets live in local.properties and reach the code as BuildConfig constants, never in Git
  • Debug with the logging interceptor and the Network Inspector; use https://
  • Coil 3 (coil-compose + coil-network-okhttp) loads, caches and cancels images; error and placeholder painters handle missing posters

25 Recap Questions

  • Which HTTP method and status code would you expect when creating a resource? When it already exists?
  • Why must SearchResponse.search be nullable? What happens if it is not?
  • What do @GET, @Query and the suspend modifier do in the MovieApi interface?
  • Why is the Retrofit instance created in the AppContainer and not in the ViewModel?
  • Which two exception types do you catch around a Retrofit call, and what does each one mean?
  • What happens if you commit your API key, and how do you fix it?
  • Why does Coil 3 need coil-network-okhttp, and how do you handle "Poster": "N/A"?

Questions?