Android SharedPreferences Tutorial and Example

Build an Android REST client with OkHttp

Implementing a REST client with OkHttp in Android gives an app a lightweight, mature HTTP engine. It manages connection pooling, timeouts, request cancellation and response delivery while leaving JSON mapping and application architecture in your hands. It works well for public feeds, authenticated services and endpoints that accept JSON.

The example below targets a Kotlin application that reads observations from the Australian Bureau of Meteorology feed for Sydney Observatory Hill. It covers GET and POST requests, URL construction, parsing, error states and a repository boundary. The same design can support a Melbourne transport dashboard, a Brisbane booking flow or a Perth delivery application when each provider’s API contract replaces the sample feed.

A production client should also respect Australian data conventions. Send ISO 8601 timestamps over the network, represent prices as integer cents in AUD, identify the country as AU and format consumer-facing dates as day/month/year. Phone numbers use the +61 calling format, while daylight-saving changes mean local times should use an IANA zone such as Australia/Sydney instead of a fixed UTC offset.

Client option What your code manages Main strength Main trade-off
OkHttp directly Requests, responses and JSON parsing Full control and a small API Manual data mapping
Retrofit over OkHttp Interfaces, converters and coroutine calls Concise, strongly typed services Additional abstraction
HttpURLConnection Connections, streams and threading No external dependency Older and more verbose

Set up the project and dependencies

Start with the OkHttp and coroutine dependencies in the app module. These baseline versions work with many Kotlin projects, although the selected releases should remain compatible with the project’s minimum Android SDK.

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}

Android applications need the INTERNET permission in the main manifest. Public API endpoints should use HTTPS. Since Android 9, cleartext HTTP is disabled by default, so production security should not be weakened merely to make a request succeed. A local development server can use a narrowly scoped debug configuration.

Create one shared OkHttpClient for the application rather than constructing one for every screen. Reusing it preserves pooled connections and gives all calls consistent timeout, authentication and observability settings.

Define the API contract

Give the response a small domain model instead of passing JSONObject through every layer. For the Bureau feed, the useful values are the station name, air temperature and observation time.

data class Observation(
    val station: String,
    val temperatureC: Double?,
    val observedAt: Instant?
)

The Bureau response uses GeoJSON: a features array contains a properties object for each station. Parse optional measurements defensively because a feed can omit a reading or change its field arrangement.

fun JSONObject.toObservation(): Observation {
    val properties = getJSONArray("features")
        .getJSONObject(0)
        .getJSONObject("properties")

    return Observation(
        station = properties.optString("name", "Unknown"),
        temperatureC = properties.optDouble("airTemp")
            .takeUnless { it.isNaN() },
        observedAt = properties.optString("dateTime")
            .takeIf { it.isNotBlank() }
            ?.let { value ->
                runCatching {
                    OffsetDateTime.parse(value).toInstant()
                }.getOrNull()
            }
    )
}

Keep wire data separate from display data. Convert the timestamp to Instant, then format it for the intended Australian city in the presentation layer. A Sydney user may see AEST or AEDT, while someone in Melbourne sees the corresponding Victorian time. Neither offset should be derived by adding a fixed number of hours.

Build the request layer

Construct requests with Request.Builder and resolve them against a base HttpUrl. A trailing slash on a base path and a relative endpoint produce predictable URLs. Query values should be encoded with HttpUrl.Builder rather than inserted through string concatenation.

The following client connects OkHttp’s callback API to a suspend function. Cancellation cancels the underlying call, while unsuccessful HTTP statuses become exceptions rather than being mistaken for usable JSON.

class RestClient(
    private val baseUrl: HttpUrl,
    private val httpClient: OkHttpClient
) {
    suspend fun get(path: String): JSONObject = execute(
        Request.Builder().url(url(path)).get().build()
    )

    suspend fun postJson(path: String, payload: JSONObject): JSONObject {
        val body = payload.toString().toRequestBody(JSON)
        return execute(
            Request.Builder().url(url(path)).post(body).build()
        )
    }

    private suspend fun execute(request: Request): JSONObject =
        suspendCancellableCoroutine { continuation ->
            val call = httpClient.newCall(request)
            continuation.invokeOnCancellation { call.cancel() }

            call.enqueue(object : Callback {
                override fun onFailure(call: Call, e: IOException) {
                    if (continuation.isActive) {
                        continuation.resumeWithException(e)
                    }
                }

                override fun onResponse(call: Call, response: Response) {
                    val code = response.code
                    val body = response.use { it.body?.string().orEmpty() }

                    if (code !in 200..299) {
                        if (continuation.isActive) {
                            continuation.resumeWithException(
                                HttpStatusException(code, body)
                            )
                        }
                        return
                    }

                    try {
                        val json = JSONObject(body)
                        if (continuation.isActive) continuation.resume(json)
                    } catch (e: JSONException) {
                        if (continuation.isActive) {
                            continuation.resumeWithException(e)
                        }
                    }
                }
            })
        }

    private fun url(path: String): HttpUrl =
        baseUrl.resolve(path) ?: error("Invalid API path: $path")

    companion object {
        private val JSON = "application/json; charset=utf-8".toMediaType()
    }
}

class HttpStatusException(
    val statusCode: Int,
    val responseBody: String
) : IOException("HTTP $statusCode")

Configure sensible connection, read and overall call timeouts on the shared client. Protected endpoints can use an interceptor that reads a short-lived bearer token from secure storage. A secret embedded in source code or a mobile binary cannot be reliably protected from an informed user.

For a delivery or booking service that expects GST separately, an Australian payload might use integer minor units and explicit currency fields:

val payload = JSONObject()
    .put("currency", "AUD")
    .put("countryCode", "AU")
    .put("subtotalCents", 2595)
    .put("gstCents", 260)

The receiving API’s tax-inclusive or tax-exclusive convention should be confirmed rather than assumed.

Handle failures and background work

Handle failure in layers. A repository can convert transport, status and parsing exceptions into a Result or a sealed UI state. A timeout or DNS failure should support a retry action, while malformed JSON should be logged with sensitive payload fields removed. Raw server responses must not be displayed automatically because they can contain account or request information.

OkHttp’s automatic recovery is limited and should not be treated as a complete retry policy. A timed-out GET can be retried after a short exponential delay when connectivity returns, but blindly repeating a POST may duplicate a booking, order or payment. An idempotency key provides better protection when the remote service supports one.

Regional mobile connections can be slower than connections in central Sydney or Melbourne, so obsolete work should stop promptly when its screen closes. Let viewModelScope own the request, expose loading, success and error states, and allow the interface to retry without restarting duplicate calls during configuration changes.

Connect the client to the Android UI

Keep the ViewModel independent of Android views by placing calls behind a repository. The repository owns RestClient and maps the Bureau payload into Observation; the ViewModel exposes immutable state; the screen renders that state and sends retry requests.

class WeatherRepository(
    private val restClient: RestClient
) {
    suspend fun observation(): Result<Observation> = runCatching {
        restClient.get("fwo/IDN60901.94867.json").toObservation()
    }
}

With Jetpack Compose, collect the state using collectAsStateWithLifecycle so collection stops when the interface is not visible. A Views-based application can observe the same ViewModel with LiveData. In either case, display station details only after parsing succeeds and preserve useful error text rather than exposing a raw exception.

Test the network boundary with MockWebServer and a small recorded Bureau response. Cover a successful observation, a 404, malformed JSON and a dropped connection. Once this contract is stable, the same client can support local transport data, account balances and order endpoints without changing the interface’s error-handling model.