Everything this app shows comes from a Spring Boot API: the menu, the cart, orders, addresses, cards. This lesson is the layer that talks to it — Retrofit for describing the API, OkHttp for sending requests, kotlinx.serialization for turning JSON into Kotlin — and the configuration that decides which server a build talks to.
The standard Android networking stack has not changed shape in years, and it is worth learning properly once. The defaults are where the trouble is: three of them will bite you, and they are called out as they come up.
The API as an interface
Retrofit's idea is that an HTTP API is described by a Kotlin interface. Each method is an endpoint, its annotations are the request, and Retrofit generates the implementation at runtime:
interface PizzaApi {
// Authentication
@POST("api/auth/login")
suspend fun login(@Body body: LoginBody): AuthenticationResponse
@POST("api/auth/register")
suspend fun register(@Body body: RegisterBody): AuthenticationResponse
@Authenticated
@GET("api/auth/me")
suspend fun currentUser(): User
// Catalogue
@GET("api/products")
suspend fun products(): List<Product>
@GET("api/toppings")
suspend fun toppings(): List<Topping>
@GET("api/crusts")
suspend fun crusts(): List<Crust>
// …
Every route the app calls lives in this one file, which makes "what does this app talk to?" a question answered by reading it. The annotations cover the shape of a request:
@GET("api/carts/{id}")
suspend fun cart(@Path("id") id: String): ServerCart
@PUT("api/carts/{id}")
suspend fun replaceCart(@Path("id") id: String, @Body body: CartWriteRequest): ServerCart
@GET,@POST,@PUT,@PATCH,@DELETEwith a path relative to the base URL.@Path("id")fills{id}, URL-encoded.@Query("page")adds?page=…, also encoded — never concatenate query strings by hand; a topping called "Mac & Cheese" will find the bug for you.@Bodyserialises the parameter as the JSON request body.
Every function is suspend. Retrofit then runs the call on OkHttp's own threads and
resumes the caller with the decoded body, so calling it from a coroutine on the main thread is safe —
there is no withContext(Dispatchers.IO) to remember. A non-2xx response throws
HttpException; the next lesson turns that into something the app can act on.
For a request with no response body — a DELETE that returns 204 — the return type is
Unit, and Retrofit does not try to decode anything:
@DELETE("api/me/addresses/{id}")
suspend fun deleteAddress(@Path("id") id: String)
Wire-only types stay in the data layer
Some request and response shapes exist only on the wire — a login body, a one-field envelope around a client secret. They are declared next to the interface, not in the domain models:
data class LoginBody(val email: String, val password: String)
@Serializable
data class RegisterBody(val email: String, val password: String, val fullName: String)
@Serializable
data class SetupIntentResponse(val clientSecret: String)
@Serializable
data class PaymentMethodBody(val stripePaymentMethodId: String)
The repository interface the rest of the app sees takes two strings and returns a client secret. Nothing above the data layer knows these envelopes exist, so the API can change its envelope without a single screen noticing.
Building the client
The pieces are assembled once, in a Hilt module, and shared by every repository:
fun provideOkHttpClient(config: ApiConfig, tokenProvider: AuthTokenProvider): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(config.requestTimeoutSeconds, TimeUnit.SECONDS)
.readTimeout(config.requestTimeoutSeconds, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor(tokenProvider))
.apply {
/*
* Logging is added only to debug builds, and at BASIC — method, URL, status and
* timing. BODY would print every password and token to Logcat, which any app with
* READ_LOGS on an old or rooted device can read.
*/
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC),
)
}
}
.build()
Timeouts come first because the default is wrong for a phone. A phone on a weak signal does not fail fast — without a deadline a request can sit for minutes while the customer watches a spinner. Fifteen seconds is long enough for a cold Spring Boot start and short enough that a dead network is obvious.
Interceptors see every request and response, in order. The auth interceptor is
below. The logging interceptor is added only to debug builds, and only at BASIC — method,
URL, status, timing. BODY would print every password and token to Logcat, which is
exactly where you do not want them, even in development.
fun provideRetrofit(config: ApiConfig, client: OkHttpClient, json: Json): Retrofit =
Retrofit.Builder()
// Must end in "/": Retrofit resolves "api/products" against it like a browser
// resolves a relative link, and without the slash the last path segment is replaced.
.baseUrl(config.baseUrl)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
⚠️ The base URL must end in /. Retrofit resolves a method's path
against it the way a browser resolves a relative link, so http://host/v1 plus
api/products becomes http://host/api/products — the v1 silently
replaced. With the trailing slash it is appended, as you meant. Retrofit throws at startup for a base
URL with a path and no trailing slash, which is the friendliest possible failure; the trap is mostly in
copying a URL without one into configuration.
Attaching the token
Some routes need the customer's token and some do not; the menu is public, an address book is not. That decision belongs next to the route, not to whoever happens to call it. So the app marks authenticated routes with its own annotation:
@Authenticated
@GET("api/auth/me")
suspend fun currentUser(): User
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Authenticated
⚠️ RUNTIME retention is not optional. An annotation's default
retention keeps it in the class file but hides it from reflection, so the interceptor would find
nothing and every request would go out without a token — and the first symptom would be a 403 from a
screen that looks correct.
The interceptor reads the annotation back from the request. Retrofit tags every request it builds
with an Invocation — the interface method being called — which is how an OkHttp
interceptor can know which Retrofit method a request came from:
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val method = request.tag(Invocation::class.java)?.method()
if (method?.isAnnotationPresent(Authenticated::class.java) != true) {
return chain.proceed(request)
}
val token = runBlocking { tokenProvider.currentToken() } ?: return chain.proceed(request)
return chain.proceed(
request.newBuilder().header("Authorization", "Bearer $token").build(),
)
}
One interceptor, so there is exactly one place that knows how a token is attached. A
@Header("Authorization") parameter on each method would put that knowledge in every call
site, and the first one to forget it would ship.
runBlocking usually means something has gone wrong, and the source carries a comment
saying why it is correct here: an OkHttp interceptor is synchronous by contract and always runs on
OkHttp's background threads, never the main thread, and the token is cached in memory after the first
read. Blocking one worker thread for a memory read costs nothing.
Finally, the interceptor does not depend on the session store directly. It depends on a one-method interface:
fun interface AuthTokenProvider {
suspend fun currentToken(): String?
}
The HTTP stack needs the token; the token lives with the session; the session is restored by
calling the HTTP stack. Concrete types in both directions would be a dependency cycle that Hilt refuses
to build. An interface the stack owns breaks it — and is also exactly what a test stubs with a lambda:
AuthInterceptor { "jwt-abc" }.
JSON, and the three defaults that bite
Models are @Serializable data classes. The kotlinx.serialization compiler plugin
generates their serialisers at compile time — no reflection, which means nothing for R8 to strip out
of a release build and no surprises about Kotlin's nullability. The app has one Json
instance, and its configuration is three lines that each prevent a real bug:
val PizzaJson: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
ignoreUnknownKeys. By default kotlinx.serialization throws when the JSON has a field the class does not declare. The backend adds a field, ships first, and every installed copy of your app stops decoding that response. This is the opposite of Gson, Moshi and Swift'sCodable, and the first thing to break in production.encodeDefaults. By default a property holding its default value is not written. A request class withquantity: Int = 1would send no quantity at all, and the server would reject — or worse, assume — it.explicitNulls = false. Leave a null out of the body rather than sending"phone": null. Spring treats both the same; an omitted key is what every other client of this API sends.
Enums map to the wire with @SerialName, so a Kotlin constant can be named for Kotlin
and the JSON spelled however the server spells it. A test in the suite decodes a product with an extra
"calories" field, so removing the first setting fails the build rather than the app.
Which server: configuration, not code
"The backend" is a different machine for the emulator, a physical phone and a release build. So the
address is a build setting, written into a generated BuildConfig class per build
type:
companion object {
fun fromBuildConfig(): ApiConfig = ApiConfig(
baseUrl = BuildConfig.API_BASE_URL,
stripePublishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY.takeIf { it.isNotBlank() },
)
}
The debug value is http://10.0.2.2:8085/ unless local.properties says
otherwise (the getting-started lesson); release is the production HTTPS host. Changing environments
is a build change, so the same source ships everywhere. And ApiConfig is a class rather
than an object, so a test constructs one pointing at a local mock server and is certain
nothing reaches a real host.
Why the first request fails: cleartext
Point a debug build at a local server and the first request fails with:
java.net.UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted by network security policy
Since Android 9, plain HTTP is blocked by default. The fix is a network security config — and the detail that matters is where it lives. Release gets a strict one:
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
Debug gets a permissive one, in src/debug/res/:
<domain-config cleartextTrafficPermitted="true">
<!-- The emulator's alias for the host machine's loopback. -->
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
Gradle merges the debug source set over main only for debug builds, so a
release build never sees the permissive file. Putting cleartextTrafficPermitted="true" in
main "just for development" is how apps ship to the store accepting unencrypted traffic.
Compared with the iOS version
The SwiftUI app describes each request as an Endpoint value — path, method, whether it
needs a token — and sends it through one hand-written HTTPClient. Retrofit is the same idea
with the boilerplate generated: the interface method is the endpoint, the annotation is the flag, OkHttp
is the client. On both platforms the security-relevant decision — does this route carry the token? —
is declared next to the path, which is the property worth copying whichever library you use.
Next
A request can fail in four meaningfully different ways, and a coroutine can be cancelled mid-flight.
The next lesson turns Retrofit's, OkHttp's and the serialiser's exceptions into one error type whose
cases are decisions, and shows the CancellationException you must never swallow.