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 under src/debug/res/xml instead of src/main/res/xml means 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 CertificatePinner later 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.

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.

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
    )

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.

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 — HttpURLConnection, WebView, and any library built on the platform’s default SSLSocketFactory.

Applies only to calls made through that specific OkHttpClient instance.

Declared in

XML resource, no code.

Kotlin/Java code, via OkHttpClient.Builder().certificatePinner(…​).

Expiration safety net

Supports <pin-set expiration="2027-06-01">, after which pinning is ignored rather than bricking the app if pins were never rotated.

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

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)

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

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 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 @Serializable.

Reflection by default; a KSP-based codegen (@JsonClass(generateAdapter = true)) avoids reflection.

Reflection-based; no official codegen.

Kotlin-specific features

First-class support for sealed interface/sealed class polymorphism, value classes, default parameter values.

Good data class and nullability support; polymorphic adapters need explicit setup.

Nullability and default values are easy to get wrong (Gson can silently assign null to a non-null Kotlin property via reflection).

Multiplatform

Yes — part of the same JetBrains KMP stack as coroutines.

JVM/Android only.

JVM/Android only.

Retrofit/Ktor integration

retrofit2:converter-kotlinx-serialization; Ktor’s own ContentNegotiation json().

retrofit2:converter-moshi; a community Ktor integration exists but is less commonly used than the other two.

retrofit2:converter-gson; a community Ktor integration exists similarly.

When it is the better fit

A new Kotlin/KMP-first codebase, or one already using kotlinx.serialization for DataStore.

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 (httpClient.get("trails") { …​ }); no interface generation.

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 (Auth, Logging, HttpTimeout, ContentNegotiation) layered on top of whichever engine is chosen; can still delegate to OkHttp features when the OkHttp engine is used.

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

Response<T> (manual) or exceptions from a bare suspend return.

Exceptions by default (ClientRequestException, ServerResponseException) unless expectSuccess = false is set, then status is checked manually via HttpResponse.status.

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.

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 CronetEngine and, more practically for an app already built on OkHttp, as a drop-in OkHttp Call.Factory (CronetInterceptor / CronetCallFactory from org.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 .graphql files 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-based Response.Listener/ErrorListener API predates suspend functions) and is rarely chosen for new projects.

  • AsyncTask was 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 (an AsyncTask held a reference to its enclosing Activity and kept running past onDestroy) and threading pitfalls around its shared thread pool. suspend functions scoped to viewModelScope/lifecycleScope, as used throughout this page, are its direct replacement; see ViewModel, Lifecycle-Aware Components, Coroutines and Flow.