Networking with Retrofit, OkHttp and Ktor
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
This page covers how a TrailMate build talks to https://api.example.com/v1/: the platform rules every HTTP
client must respect, OkHttp 5 as the transport underneath everything else, Retrofit 3 as the type-safe API layer
built on top of it, and Ktor Client 3.6 as a Kotlin-Multiplatform-friendly alternative to both. It targets the
section’s baseline — Retrofit 3.0.0, OkHttp 5.5.0 (plus logging-interceptor and mockwebserver3),
Ktor Client 3.6.0, kotlinx-serialization-json 1.11.0, Moshi 1.15.2 and Gson 2.14.0. The INTERNET
permission and the android:networkSecurityConfig manifest attribute are introduced on
App Components, the Manifest and Intents; the
network_security_config.xml resource file itself is introduced on
Resources, Configuration, Localization and Themes. This page
covers what those two pieces mean for networking code, then the libraries that use them.
Coroutines, suspend functions, Flow and structured concurrency are explained in the Kotlin reference and are
not repeated here; see Coroutines Basics and
Flows. Where TrailMate stores what it fetches, see
Room Database and Paging and
Local Storage: DataStore, Files, Scoped Storage and
MediaStore for OfflineFirstTrailRepository’s Room side and its cached-vs-network merge strategy; how
`TrailApi/TrailApiClient are wired into the app with Hilt or Koin is covered on
Dependency Injection with Hilt, Dagger and Koin. Token
storage and OAuth flows for a mobile client are covered on
OAuth for Native and Mobile Apps (RFC 8252, PKCE, App Links); this
page only shows where a bearer token is attached to a request and how a refresh is triggered. Broader permission
and security topics — runtime permissions, the Keystore, biometrics — are covered on
Permissions, Privacy and Security.
Basics: Permission, Threads and Transport Security
Three platform rules apply to every HTTP client on Android, regardless of which library issues the request.
The INTERNET permission. Any network access at all requires
<!-- app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
INTERNET is a normal permission: it is granted automatically at install time and never shows a runtime
prompt, because the system cannot warn a user about network access without breaking almost every app. TrailMate
also declares ACCESS_NETWORK_STATE (also normal) so it can query connectivity, covered later on this page. See
App Components, the Manifest and Intents for the
distinction between normal, dangerous and signature permissions in general.
The main-thread rule. Since Android 3.0 (API 11), performing network I/O on the main thread throws
android.os.NetworkOnMainThreadException, by design: a blocking socket read on the UI thread can freeze the app
for seconds and trigger an ANR (Application Not Responding) dialog. Every client on this page enforces this for
you as long as it is used the way its own documentation recommends: OkHttp’s asynchronous Call.enqueue and
Retrofit’s suspend functions dispatch the actual socket work to a background thread pool and hand the result
back on whatever CoroutineDispatcher (or callback thread) the caller used, so a TrailListViewModel can call
trailApi.getTrails() directly from viewModelScope.launch without wrapping it in
withContext(Dispatchers.IO) itself — Retrofit already moved the blocking part off the calling thread before the
suspend function resumes.
Cleartext traffic and the network security config. Since Android 9 (API 28), plain HTTP (cleartext) is
blocked by default; a request to \http:// fails with an SSLHandshakeException-shaped connection error before a
single byte is sent. TrailMate’s API is HTTPS-only (https://api.example.com/v1/), so nothing special is needed
for it, but two situations still call for a network security config:
-
Local development against a plaintext backend (a laptop running the API over
http://10.0.2.2:8080/from the emulator). A debug-only override (below) keeps production cleartext-blocked while allowing it for that one host. Placing it undersrc/debug/res/xmlinstead ofsrc/main/res/xmlmeans it is compiled only into debug builds; the release build keeps the platform default of blocking cleartext everywhere. -
Certificate pinning, covered together with OkHttp’s own
CertificatePinnerlater on this page, because the network security config offers a declarative alternative to it.
<?xml version="1.0" encoding="utf-8"?>
<!-- app/src/debug/res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
OkHttp 5: the HTTP Client Underneath
Retrofit does not talk to a socket itself — it builds okhttp3.Request objects and lets an OkHttpClient
execute them, then parses whatever okhttp3.Response comes back. Ktor’s OkHttp engine does the same. Understanding
OkHttp directly therefore explains most of what both higher-level libraries do under the hood, and OkHttp’s own
API (interceptors, an Authenticator, a Cache) is also the layer where cross-cutting networking concerns
belong, regardless of which API layer sits above it.
Client Configuration and Timeouts
// core/network/src/main/kotlin/com/example/trailmate/core/network/di/OkHttpModule.kt
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
fun buildTrailMateOkHttpClient(
loggingInterceptor: okhttp3.Interceptor,
authInterceptor: okhttp3.Interceptor,
tokenAuthenticator: okhttp3.Authenticator,
cache: okhttp3.Cache,
): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.callTimeout(30, TimeUnit.SECONDS)
.cache(cache)
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.authenticator(tokenAuthenticator)
.build()
connectTimeout bounds how long the TCP/TLS handshake may take; readTimeout/writeTimeout bound the gap
between two consecutive bytes on an already-open socket, not the whole request; callTimeout is the only one
that bounds the entire call, including retries and redirects, and is what most apps should set explicitly for
a hard upper bound. OkHttpClient instances are meant to be built once and reused — each one owns a connection
pool and a thread pool — so TrailMate creates exactly one (via Hilt’s NetworkModule, see
Dependency Injection with Hilt, Dagger and Koin) and
shares it between TrailApi and any Ktor HttpClient that also needs one.
See the OkHttp repository and documentation.
Application vs. Network Interceptors
OkHttp has two interceptor chains, and confusing them is a common source of bugs. An application interceptor,
added with addInterceptor, sees the request exactly as the calling code built it and the response exactly as
the calling code will see it — it runs once per call, even if OkHttp internally retries or follows a redirect,
and it is never skipped by a cached response short-circuiting the chain. A network interceptor, added with
addNetworkInterceptor, sits right next to the socket — it can run more than once per call (once per redirect
or retry), it sees the request after OkHttp added headers like Content-Length, and it is skipped entirely for a
response served from the cache, because no network call happened.
// core/network/src/main/kotlin/com/example/trailmate/core/network/AuthInterceptor.kt
package com.example.trailmate.core.network
import okhttp3.Interceptor
import okhttp3.Response
class AuthInterceptor(
private val tokenStore: TokenStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenStore.currentAccessToken()
val authenticatedRequest = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
return chain.proceed(authenticatedRequest)
}
}
AuthInterceptor is an application interceptor: attaching an Authorization header is a decision about the
logical request, not about bytes on the wire, so it belongs before caching, retries and redirects are considered.
A logging interceptor, by contrast, is usually added as a network interceptor in debug builds, so the log
line shows what actually went over the socket (final headers, compressed body size) rather than what the calling
code asked for:
// core/network/src/debug/kotlin/com/example/trailmate/core/network/di/DebugLoggingModule.kt
import okhttp3.logging.HttpLoggingInterceptor
val debugLoggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
// client.newBuilder().addNetworkInterceptor(debugLoggingInterceptor).build()
HttpLoggingInterceptor ships in the separate com.squareup.okhttp3:logging-interceptor artifact and is added
only in debug/internal build variants — logging full request and response bodies (Level.BODY) in a release
build risks writing tokens or personal data to Logcat. A common mistake is registering the logging interceptor
with addInterceptor and expecting it to show gzip-decompressed sizes and redirect targets; only
addNetworkInterceptor sees those.
See the OkHttp repository and documentation (interceptors and the logging-interceptor module).
Authenticator: Refreshing an Expired Token
An Authenticator reacts to a 401 Unauthorized response by producing a new request to retry with — typically
after refreshing an access token — instead of every call site having to detect expiry itself.
// core/network/src/main/kotlin/com/example/trailmate/core/network/TokenAuthenticator.kt
package com.example.trailmate.core.network
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
class TokenAuthenticator(
private val tokenStore: TokenStore,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (response.request.header("Authorization") == null) return null
// Avoid an infinite retry loop if the refreshed token is rejected too.
if (responseCount(response) >= 2) return null
val refreshedToken = tokenStore.refreshAccessTokenBlocking() ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $refreshedToken")
.build()
}
private fun responseCount(response: Response): Int {
var result = 1
var prior = response.priorResponse
while (prior != null) {
result++
prior = prior.priorResponse
}
return result
}
}
authenticate runs on an OkHttp dispatcher thread, not the main thread, but it is still a synchronous callback — it cannot call a suspend function directly, so refreshAccessTokenBlocking() must block that thread (for
example with runBlocking around a single network call to the refresh endpoint) rather than launch a coroutine
and return immediately. TokenStore also serializes concurrent refreshes so that two requests failing with 401
at the same time trigger one refresh call, not two. Returning null tells OkHttp to give up and propagate the
401 to the caller, which is why the retry-count guard above matters — without it, a backend that keeps rejecting
the refreshed token would retry forever.
See the OkHttp repository and documentation.
HTTP Cache and Cache-Control
// core/network/src/main/kotlin/com/example/trailmate/core/network/di/CacheModule.kt
import okhttp3.Cache
import java.io.File
fun buildTrailCache(cacheDir: File): Cache =
Cache(
directory = File(cacheDir, "http_cache"),
maxSize = 10L * 1024 * 1024, // 10 MiB
)
See okhttp — Caching.
OkHttp’s Cache stores responses on disk keyed by URL and honors standard HTTP caching semantics: a
Cache-Control: max-age=300 response header lets OkHttp serve a repeat GET trails?region=alps from disk for
five minutes without touching the network at all, and an ETag/Last-Modified pair lets it issue a conditional
If-None-Match/If-Modified-Since request that a 304 Not Modified response answers with almost no bytes on
the wire. This only helps if the server sends caching headers — OkHttp never invents freshness on its own — and it only applies to GET requests with a cacheable response code. A CacheControl object can also be
attached to an outgoing request to force behavior from the client side, e.g.
CacheControl.Builder().noCache().build() to always revalidate, or a maxStale duration to accept a slightly
expired cached response when offline. This HTTP-level cache is a transport optimization; it is not a
replacement for TrailMate’s own Room-backed offline-first cache, described on
Room Database and Paging, which survives the OkHttp cache being
cleared and models domain objects rather than raw HTTP responses.
See the OkHttp repository and documentation.
Certificate Pinning: CertificatePinner vs. Network Security Config
Certificate pinning rejects a TLS connection unless the server’s certificate chain matches a known public-key hash, protecting against a compromised or coerced certificate authority. Android offers two independent ways to pin, and they are not mutually exclusive:
network_security_config.xml <pin-set> |
OkHttp CertificatePinner |
|
|---|---|---|
Scope |
Applies to every HTTP client on the platform that respects the config — |
Applies only to calls made through that specific |
Declared in |
XML resource, no code. |
Kotlin/Java code, via |
Expiration safety net |
Supports |
No built-in expiration; an app must ship an update to rotate or relax pins. |
Typical use |
Pinning that should apply app-wide, including to WebView traffic, with no code changes on updates. |
Pinning scoped to one backend’s OkHttp/Retrofit/Ktor traffic, or when pin rotation needs to ship through remote config rather than an XML resource. |
<?xml version="1.0" encoding="utf-8"?>
<!-- app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-06-01">
<pin digest="SHA-256">7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=</pin>
<pin digest="SHA-256">fwza0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1oE=</pin>
</pin-set>
</domain-config>
</network-security-config>
// core/network/src/main/kotlin/com/example/trailmate/core/network/di/PinningModule.kt
import okhttp3.CertificatePinner
val trailApiPinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=")
.add("api.example.com", "sha256/fwza0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1oE=")
.build()
// OkHttpClient.Builder().certificatePinner(trailApiPinner)
Both examples pin two hashes — the currently deployed certificate’s public key and a backup key for the next
planned rotation — which is the safe pattern: pinning only one hash means an outage the moment the certificate
is renewed. The hash values above are placeholders; real pins are computed from the backend’s actual certificate
chain, never invented or copied from an unrelated site. See
OkHttp repository and documentation (CertificatePinner) and
Android Developers — Network security configuration, Certificate pinning.
EventListener: Observing a Call’s Lifecycle
EventListener reports the timeline of a call — DNS lookup, connection, TLS handshake, request/response
headers and body — without touching the request or response itself, which makes it a good fit for performance
metrics and structured logging that must not risk mutating a request the way an interceptor could.
// core/network/src/main/kotlin/com/example/trailmate/core/network/TrailMetricsEventListener.kt
package com.example.trailmate.core.network
import okhttp3.Call
import okhttp3.EventListener
class TrailMetricsEventListener : EventListener() {
override fun callStart(call: Call) {
MetricsLog.d("network", "start ${call.request().url}")
}
override fun callEnd(call: Call) {
MetricsLog.d("network", "end ${call.request().url}")
}
override fun callFailed(call: Call, ioe: java.io.IOException) {
MetricsLog.w("network", "failed ${call.request().url}: ${ioe.message}")
}
}
// OkHttpClient.Builder().eventListenerFactory { TrailMetricsEventListener() }
See okhttp — Events.
A new EventListener instance is required per call (hence eventListenerFactory, not a single shared instance),
because several calls can be in flight concurrently and the listener is not required to be thread-safe across
calls. See the OkHttp repository and documentation.
Retrofit 3: Type-Safe HTTP APIs
Retrofit turns an interface into an HTTP client implementation at runtime, so call sites work with typed Kotlin
functions instead of building Request objects by hand.
// core/network/src/main/kotlin/com/example/trailmate/core/network/TrailApi.kt
package com.example.trailmate.core.network
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.PUT
import retrofit2.http.Query
import kotlinx.serialization.Serializable
interface TrailApi {
@GET("trails")
suspend fun getTrails(
@Query("page") page: Int,
@Query("region") region: String? = null,
): List<TrailDto>
@GET("trails/{id}")
suspend fun getTrail(@Path("id") id: String): Response<TrailDto>
@PUT("trails/{id}/favorite")
suspend fun setFavorite(
@Path("id") id: String,
@Body body: SetFavoriteRequest,
): Response<Unit>
}
// Sent through the kotlinx.serialization converter, so it needs a generated serializer too.
@Serializable
data class SetFavoriteRequest(val isFavorite: Boolean)
See retrofit — README.
@GET/@PUT (and @POST/@DELETE/@PATCH) name the HTTP method and a relative URL template. @Path("id")
substitutes into a {id} placeholder in that template; @Query("page") appends ?page=…, and a null
default like region above is simply omitted from the URL rather than sent as the literal string "null".
@Body serializes its argument as the request body using whichever converter Retrofit was built with — with the
kotlinx.serialization converter that means the body type must be @Serializable, like every DTO it parses, or the
call throws SerializationException: Serializer for class 'SetFavoriteRequest' is not found.
Response<T> vs. a bare suspend return type. getTrails returns List<TrailDto> directly: Retrofit
deserializes a successful body and throws retrofit2.HttpException for any non-2xx status, or an IOException
for a network failure. getTrail and setFavorite instead return Response<TrailDto>/Response<Unit>, which
never throws for an HTTP error — response.isSuccessful, response.code(), response.body() and
response.errorBody() are all inspected explicitly. The Response<T> form is worth the extra boilerplate
whenever the caller needs to distinguish 404 (trail deleted) from 403 (not entitled) from 500, which a bare
HttpException.code() can also do, but which reads more naturally as an explicit branch. TrailMate wraps both
shapes into one repository-facing type so OfflineFirstTrailRepository never has to catch two different kinds of
exception depending on which style a given endpoint happens to use:
// core/network/src/main/kotlin/com/example/trailmate/core/network/NetworkResult.kt
package com.example.trailmate.core.network
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
sealed interface NetworkResult<out T> {
data class Success<T>(val value: T) : NetworkResult<T>
data class ApiError(val code: Int, val message: String?) : NetworkResult<Nothing>
data class NetworkError(val cause: IOException) : NetworkResult<Nothing>
}
suspend fun <T> safeApiCall(block: suspend () -> T): NetworkResult<T> =
try {
NetworkResult.Success(block())
} catch (httpException: HttpException) {
NetworkResult.ApiError(httpException.code(), httpException.message())
} catch (ioException: IOException) {
NetworkResult.NetworkError(ioException)
}
suspend fun <T> Response<T>.toNetworkResult(): NetworkResult<T> =
if (isSuccessful) {
body()?.let { NetworkResult.Success(it) }
?: NetworkResult.ApiError(code(), "Empty body")
} else {
NetworkResult.ApiError(code(), errorBody()?.string())
}
See retrofit — README.
NetworkResult is deliberately a sealed interface rather than Kotlin’s Result, because it needs to distinguish
a rejected request (ApiError, with a status code the UI or repository can act on) from a request that never
reached the server (NetworkError, which is when OfflineFirstTrailRepository falls back to the Room cache). A
common mistake is catching only Exception around a Retrofit call: that also swallows CancellationException
from a cancelled coroutine, which must always be allowed to propagate. See
Coroutine Context,
Cancellation and Exceptions.
Building the client wires an OkHttpClient, a base URL and a converter together:
// core/network/src/main/kotlin/com/example/trailmate/core/network/di/RetrofitModule.kt
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
fun buildTrailApi(
okHttpClient: okhttp3.OkHttpClient,
baseUrl: String = "https://api.example.com/v1/", // overridden by tests with a MockWebServer URL
): TrailApi {
val json = Json { ignoreUnknownKeys = true }
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
return retrofit.create(TrailApi::class.java)
}
ignoreUnknownKeys = true matters in practice: without it, the backend adding a new field to TrailDto breaks
every existing app version that has not been updated yet. See
the Retrofit repository and documentation and
Retrofit — kotlinx.serialization converter.
Ktor Client 3.6: a Kotlin Multiplatform Alternative
Ktor’s client is engine-based: the same request-building code runs over different HTTP engines, one of which
(OkHttp) reuses the exact OkHttpClient already configured above — useful when a :core:network module is
shared with a KMP target (a desktop or iOS build of the same domain layer) that cannot use OkHttp on every
platform, since Ktor also ships engines like CIO, Darwin and Js.
// core/network/src/main/kotlin/com/example/trailmate/core/network/TrailApiClient.kt
package com.example.trailmate.core.network
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.http.URLProtocol
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
class TrailApiClient(
private val tokenStore: TokenStore,
// The app's shared OkHttpClient, so Ktor reuses its connection pool, interceptors and Authenticator.
sharedOkHttpClient: OkHttpClient? = null,
// Any HttpClientEngine: OkHttp in production, a MockEngine in tests.
engine: HttpClientEngine = OkHttp.create { preconfigured = sharedOkHttpClient },
) {
private val httpClient = HttpClient(engine) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) {
level = LogLevel.INFO
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
}
install(Auth) {
bearer {
loadTokens {
BearerTokens(
accessToken = tokenStore.currentAccessToken(),
refreshToken = "",
)
}
refreshTokens {
val refreshed = tokenStore.refreshAccessTokenBlocking()
refreshed?.let { BearerTokens(accessToken = it, refreshToken = "") }
}
}
}
defaultRequest {
url {
protocol = URLProtocol.HTTPS
host = "api.example.com"
encodedPath = "/v1/" + encodedPath.removePrefix("/")
}
}
}
suspend fun getTrails(page: Int, region: String? = null): List<TrailDto> =
httpClient.get("trails") {
parameter("page", page)
region?.let { parameter("region", it) }
}.body()
}
The engine parameter is typed as the general HttpClientEngine, not the OkHttp factory object, so a test can
pass a MockEngine (below); its default, OkHttp.create { preconfigured = sharedOkHttpClient }, builds the OkHttp
engine on top of the app’s existing OkHttpClient instead of creating a second one.
ContentNegotiation with the json() extension is Ktor’s equivalent of a Retrofit converter factory — serialization/deserialization keyed on the Content-Type header. defaultRequest centralizes the base URL and
scheme so every call site writes only the path ("trails"), the same role baseUrl plays for Retrofit.
Logging is Ktor’s own request/response logger, independent of OkHttp’s HttpLoggingInterceptor, and should
likewise be dialed down (or omitted) outside debug builds. HttpTimeout plays the same role as OkHttp’s
per-client timeouts, but is configured on the Ktor client even when the underlying engine is OkHttp. The Auth
plugin’s bearer provider attaches the current token to every request and calls refreshTokens automatically on
a 401, the same responsibility OkHttp’s TokenAuthenticator has above — an app that already built a
TokenAuthenticator for its OkHttp/Retrofit stack does not need to duplicate that refresh logic if it reuses the
same OkHttpClient as Ktor’s engine, since the Authenticator would already run.
See Ktor — Create and configure a client, Ktor — Content negotiation and serialization, Ktor — Default request, Ktor — Logging, Ktor — Timeout and Ktor — Authentication and authorization.
See also Networking with the Ktor Client in the Kotlin Multiplatform section for Ktor in shared code, with per-platform engines.
Comparing the Serialization Libraries
TrailMate’s converter choice matters beyond the networking layer, since Room, DataStore (via a custom
Serializer<T>) and unit test fixtures often reuse the same data classes.
| kotlinx.serialization | Moshi | Gson | |
|---|---|---|---|
Mechanism |
Kotlin compiler plugin generates a serializer at compile time from |
Reflection by default; a KSP-based codegen ( |
Reflection-based; no official codegen. |
Kotlin-specific features |
First-class support for |
Good |
Nullability and default values are easy to get wrong (Gson can silently assign |
Multiplatform |
Yes — part of the same JetBrains KMP stack as coroutines. |
JVM/Android only. |
JVM/Android only. |
Retrofit/Ktor integration |
|
|
|
When it is the better fit |
A new Kotlin/KMP-first codebase, or one already using |
An existing codebase already standardized on Moshi/Square tooling, or one that needs codegen without adopting a compiler plugin. |
Legacy codebases already depending on it; not the recommended starting point for new Kotlin code. |
TrailMate uses kotlinx.serialization, matching the baseline recommendation for new Kotlin-first projects; Moshi is the strongest alternative when a codebase already standardizes on Square’s libraries. See Kotlin — Serialization, Moshi — README and Gson — README.
Comparing Retrofit and Ktor
| Retrofit | Ktor Client | |
|---|---|---|
API style |
Annotated interface, implementation generated at runtime by dynamic proxy. |
Builder/DSL-style function calls ( |
Platform reach |
Android/JVM only. |
Kotlin Multiplatform — the same client code can target Android, iOS, desktop and JS/Wasm with a different engine. |
Underlying transport |
Always OkHttp. |
Pluggable engine: OkHttp, CIO, Darwin, Js and others. |
Cross-cutting concerns |
Delegated entirely to OkHttp interceptors/Authenticator/Cache. |
First-party plugins ( |
Maturity on Android |
Long-established; the default choice in most existing Android codebases and tutorials. |
Actively growing, most attractive when the same data layer needs to run outside Android too. |
Error handling shape |
|
Exceptions by default ( |
Neither is a strictly better choice: an Android-only app with no plan to share code outside the JVM has little
reason to add Ktor’s extra engine abstraction, while a codebase that already shares domain and data-layer code
across Android and iOS/desktop targets benefits from Ktor’s single API surface. TrailMate’s pages.md-style
running example keeps both TrailApi and TrailApiClient side by side precisely to show the same endpoints
expressed both ways.
Monitoring Connectivity: ConnectivityManager as a Flow
OfflineFirstTrailRepository needs to know when connectivity comes back to retry a failed sync, without polling.
ConnectivityManager.NetworkCallback reports connectivity changes through callbacks, which
callbackFlow adapts into a coroutine-friendly Flow:
// core/network/src/main/kotlin/com/example/trailmate/core/network/ConnectivityObserver.kt
package com.example.trailmate.core.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
class ConnectivityObserver(context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
}
override fun onLost(network: Network) {
trySend(false)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
val validated = networkCapabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED,
)
trySend(validated)
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
awaitClose is mandatory in a callbackFlow that registers a system callback: it runs when the collecting
coroutine is cancelled, and unregistering here is what stops NetworkCallback from leaking past the lifetime of
whoever collected the flow. NET_CAPABILITY_VALIDATED distinguishes "connected to a Wi-Fi access point" from
"connected to the internet" — a captive portal or a router with no upstream link reports connectivity without
validation. TrailSyncWorker (see
Background Work: WorkManager, Services and Alarms) collects
isOnline to decide whether to attempt a sync immediately or let WorkManager’s own network constraint handle
retrying later.
See Android Developers — Monitor connectivity status and connection metering,
Android Developers — ConnectivityManager.NetworkCallback and
Flows (section on callbackFlow).
Testing Network Code
Both stacks are designed to be tested against a real (but local, fake) HTTP server rather than by mocking the client classes directly, which exercises the actual serialization and request-building code.
// core/network/src/test/kotlin/com/example/trailmate/core/network/TrailApiTest.kt
package com.example.trailmate.core.network
import kotlinx.coroutines.test.runTest
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
class TrailApiTest {
private lateinit var server: MockWebServer
private lateinit var trailApi: TrailApi
@Before
fun setUp() {
server = MockWebServer()
server.start()
trailApi = buildTrailApi(okHttpClient = okhttp3.OkHttpClient(), baseUrl = server.url("/v1/").toString())
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun `getTrails parses a successful response`() = runTest {
server.enqueue(
MockResponse.Builder()
.code(200)
.body("""[{"id":"t1","name":"Eagle Ridge"}]""")
.addHeader("Content-Type", "application/json")
.build(),
)
val trails = trailApi.getTrails(page = 0)
assertEquals(1, trails.size)
assertEquals("Eagle Ridge", trails.first().name)
val recordedRequest = server.takeRequest()
assertEquals("/v1/trails?page=0", recordedRequest.target)
}
}
mockwebserver3 starts a real local HTTP server on a loopback port, so TrailApi (or a Ktor HttpClient
pointed at server.url(…)) runs its full request-building and parsing path against it; server.takeRequest()
then asserts on the request that was actually sent — headers, path, query parameters, body — which an
interceptor-only test cannot. Ktor’s own approach avoids a real socket entirely with MockEngine:
// core/network/src/test/kotlin/com/example/trailmate/core/network/TrailApiClientTest.kt
package com.example.trailmate.core.network
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.HttpHeaders
import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
class TrailApiClientTest {
@Test
fun `getTrails parses a successful response`() = runTest {
val mockEngine = MockEngine { request ->
respond(
content = """[{"id":"t1","name":"Eagle Ridge"}]""",
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = TrailApiClient(tokenStore = FakeTokenStore(), engine = mockEngine)
val trails = client.getTrails(page = 0)
assertEquals(1, trails.size)
}
}
MockEngine intercepts at the Ktor engine layer instead of opening a socket, which makes it faster and avoids a
port-binding flake in CI, at the cost of not exercising the real OkHttp transport underneath. Both approaches are
legitimate; TrailMate uses MockWebServer for TrailApi (because it also needs to assert on OkHttp-level details
like the Authorization header the AuthInterceptor attached) and MockEngine for TrailApiClient (because
its Ktor-specific plugin behavior, like ContentNegotiation, is what needs covering). See
Unit Testing with JUnit, Mockito, MockK and
Robolectric for the rest of the testing stack (runTest, kotlinx-coroutines-test, Truth assertions) used
above. See the OkHttp repository (mockwebserver3 module) and
Ktor — Testing a Ktor client.
WebSockets
Both stacks also support a persistent, bidirectional WebSocket connection, useful for a feature TrailMate does not have today (live position sharing during a group hike) but worth knowing where it would go. OkHttp’s API is callback-based:
// core/network/src/main/kotlin/com/example/trailmate/core/network/LiveHikeSocket.kt
package com.example.trailmate.core.network
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
class LiveHikeSocket(private val okHttpClient: OkHttpClient) {
fun connect(hikeId: String): WebSocket {
val request = Request.Builder()
.url("wss://api.example.com/v1/hikes/$hikeId/live")
.build()
return okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
// Parse a position update and forward it to a Flow/StateFlow.
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
// Schedule a reconnect with backoff.
}
})
}
}
Ktor’s WebSockets plugin exposes the same capability through a suspend-friendly DefaultClientWebSocketSession
instead of callbacks, which fits more naturally into a coroutine-based repository:
// core/network/src/main/kotlin/com/example/trailmate/core/network/LiveHikeKtorSocket.kt
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
// install(WebSockets) on the HttpClient, then:
suspend fun collectLiveHike(httpClient: io.ktor.client.HttpClient, hikeId: String) {
httpClient.webSocket(urlString = "wss://api.example.com/v1/hikes/$hikeId/live") {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
// Parse a position update and forward it to a Flow/StateFlow.
}
}
}
}
Cronet, Apollo and Other Options
A few other pieces of the networking landscape are worth knowing about even though TrailMate does not use them:
-
Cronet is Chromium’s network stack, exposed to Android as a library with its own
CronetEngineand, more practically for an app already built on OkHttp, as a drop-in OkHttpCall.Factory(CronetInterceptor/CronetCallFactoryfromorg.chromium.net:cronet-okhttp). Its main draw is protocol support Chromium tunes aggressively (HTTP/3 and QUIC, connection migration across Wi-Fi/cellular handoffs) which can measurably improve tail latency on flaky mobile networks; it is worth evaluating for a production app once request volume justifies profiling, not a default starting point. See Android Developers — Perform network operations using Cronet. -
Apollo Kotlin (
com.apollographql.apollo) is the standard GraphQL client for Kotlin/Android, generating typed query and mutation classes from.graphqlfiles and a schema, with its own normalized cache. It is mentioned here only because a REST-based app like TrailMate that later grows a GraphQL backend would reach for it instead of hand-rolling GraphQL requests over Retrofit; it is out of scope for this REST-focused page.
Legacy: HttpURLConnection, Volley and AsyncTask
Older Android code and some of the books behind this section’s research use tools that predate the stack above. They are mentioned here as context for reading legacy code, not as a recommended starting point:
-
HttpURLConnection(java.net) is the JDK’s built-in HTTP client and, before OkHttp/Retrofit became standard, the most common way to make a request on Android. It requires manually opening a connection, setting request properties, checking the response code, and closing streams — everything OkHttp automates — and has no interceptor, connection-pooling or caching API. New code should not use it directly. -
Volley (
com.android.volley) was Google’s own request-queue library, offering request prioritization, cancellation and an image-loading cache before Coil/Glide existed. It is still maintained but has not kept pace with Kotlin coroutines (its callback-basedResponse.Listener/ErrorListenerAPI predatessuspendfunctions) and is rarely chosen for new projects. -
AsyncTaskwas the pre-coroutine way to run work off the main thread and post a result back to it; it was deprecated in API 30 because of lifecycle leaks (anAsyncTaskheld a reference to its enclosingActivityand kept running pastonDestroy) and threading pitfalls around its shared thread pool.suspendfunctions scoped toviewModelScope/lifecycleScope, as used throughout this page, are its direct replacement; see ViewModel, Lifecycle-Aware Components, Coroutines and Flow.