← Back to books

Chapter 1: Your First OpenAI-Powered Compose App

Project: Spark

Every chapter in this book ships a working Android app. This one ships the smallest possible app that talks to OpenAI — and the most important one, because every other chapter is built on top of the decisions we make here.

The app is called Spark. It has one screen: a text field, a button, and a result area. You type a prompt, you get a model response. That's it.

If that sounds trivial, look at what's actually hiding inside it:

  • How the Responses API is shaped, and how it differs from the old Chat Completions API most tutorials still teach.
  • How to model requests and responses as Kotlin types instead of poking at raw JSON.
  • How to represent "loading, success, error, empty" in Compose without your UI turning into a pile of booleans.
  • How to keep your API key out of your APK — the single decision that separates a demo from a product.
  • How to know what a request costs before your billing dashboard tells you.

We will build Spark twice, in a sense. First the honest, quick version that talks directly to OpenAI from the device, so you can see something work in ten minutes. Then we will break that version — literally, by pulling the key back out of a release APK — and rebuild it properly behind a proxy. The second version is the one every later chapter uses.

By the end of this chapter you will have a :core:openai module that Chapters 2 through 15 will keep extending.


1.1 What you need before you start

You need three things:

An OpenAI account with API access and a payment method. The API is not the ChatGPT subscription; they are billed separately. A ChatGPT Plus subscription gives you zero API credit. Go to the API dashboard, create a project, and add a small amount of credit. Everything in this book, done twice, costs a few dollars at most — except the realtime chapters, which we will budget carefully when we get there.

A usage limit set on day one. In the dashboard, set a hard monthly limit. Not because you plan to run away with costs, but because a retry loop with a bug in it can burn through your card while you sleep. Set it to something you would not mind losing. Do this before you write a line of code.

Android Studio and a modern toolchain. Here is the version set this book was written and tested against:

# gradle/libs.versions.toml
[versions]
agp = "8.12.0"
kotlin = "2.2.20"
ksp = "2.2.20-2.0.2"
composeBom = "2025.09.00"
coreKtx = "1.17.0"
lifecycle = "2.9.4"
activityCompose = "1.11.0"
coroutines = "1.10.2"
serialization = "1.9.0"
ktor = "3.2.3"
hilt = "2.57"
hiltNavigationCompose = "1.3.0"

[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

A note on versions. Android libraries move fast and OpenAI's model catalog moves faster. Pin your versions in libs.versions.toml, as above, and treat the book's numbers as a known-good baseline rather than gospel. The companion repository keeps a versions.md that tracks what has drifted since publication.

Why Ktor and not Retrofit? Both work. I am using Ktor across this book for one specific reason: Chapters 2, 10 and 11 need Server-Sent Events and WebSockets, and Ktor gives you HTTP, SSE and WebSocket in one client with one auth configuration. Using Retrofit for the request/response chapters and then bolting OkHttp's EventSource and WebSocket on top for the streaming chapters means three ways of doing auth in one codebase. If your team is already deep in Retrofit, everything in this chapter maps over cleanly — the DTOs are identical and the interfaces are one-liners.


1.2 The Responses API in one page

Most OpenAI tutorials you'll find teach POST /v1/chat/completions with a messages array. That endpoint still works, but it is not where OpenAI is building anymore. The Responses API is the primary surface: it is what image generation, file search, web search, function calling and the built-in tools all hang off, and it's the one API you will use in almost every chapter of this book. Learn it once, here.

The shape is refreshingly small.

The request:

curl https://api.openai.com/v1/responses \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.6-luna",
    "instructions": "You are a terse assistant. Answer in one sentence.",
    "input": "Why is the sky blue?",
    "max_output_tokens": 200,
    "store": false
  }'

Four fields carry almost all the meaning:

  • model — which model handles this request. More on choosing one in a moment.
  • instructions — the system-level steer. Who the model is, how it should behave, what it must never do. This is the equivalent of the old system message.
  • input — the user's turn. It can be a plain string, as above, or an array of typed content items when you start sending images (Chapter 5) and audio (Chapter 9).
  • store — whether OpenAI retains the response server-side so you can chain off it later. It defaults to true. I am setting it to false deliberately and we'll come back to why in §1.9.

The response:

{
  "id": "resp_0a1b2c3d",
  "object": "response",
  "status": "completed",
  "model": "gpt-5.6-luna",
  "output": [
    {
      "type": "message",
      "id": "msg_9f8e7d",
      "role": "assistant",
      "content": [
        {
          "type": "output_text",
          "text": "Sunlight scatters off air molecules, and shorter blue wavelengths scatter most.",
          "annotations": []
        }
      ]
    }
  ],
  "usage": {
    "input_tokens": 29,
    "output_tokens": 17,
    "total_tokens": 46
  }
}

Here is the detail that trips up almost everyone writing their first Android client, so I want it in bold:

output is a list of heterogeneous items, and the message you want is not guaranteed to be at index 0.

The current models are reasoning models. When reasoning is enabled, the response can contain a reasoning item before the message item. When you use tools (Chapter 4), it can contain function_call items instead of a message. When you use the built-in image tool (Chapter 6), it contains an image_generation_call. If you write output[0].content[0].text you will ship a NullPointerException to production the first time someone triggers a reasoning path.

The correct read, always:

Find the item whose type is "message". Inside it, find the content parts whose type is "output_text". Concatenate their text.

The official SDKs paper over this with an output_text convenience property. We're on Android talking raw HTTP, so we implement it ourselves — once, correctly, in one place.

Choosing a model

At the time of writing, the frontier family is gpt-5.6, in three sizes:

Model ID Positioning Input / MTok Output / MTok
gpt-5.6-sol (alias gpt-5.6) Flagship, complex reasoning and coding $5 $30
gpt-5.6-terra Balances intelligence and cost $2.50 $15
gpt-5.6-luna Cost-sensitive, high-volume $1 $6

All three take text and image input, all three support tools, all three expose a reasoning.effort dial that runs from none up through max.

For Spark — and for most of the mobile features in this book — start at luna with reasoning.effort set to none or low. Mobile users are staring at a spinner. A frontier model burning 4,000 reasoning tokens before it emits a single visible character is a terrible mobile experience and a large bill. Reach for terra or sol when a feature genuinely needs the reasoning depth, and measure the difference rather than assuming it.

Model IDs will change. They change every few months. This is why we are about to put every one of them in a single file.


1.3 Setting up the module

Create a library module, :core:openai. Everything OpenAI-related lives here for the rest of the book, and no Compose code ever imports Ktor directly.

spark/
├── app/                    # the Spark UI
└── core/
    └── openai/             # the client every chapter reuses

core/openai/build.gradle.kts:

plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.hilt)
    alias(libs.plugins.ksp)
}

android {
    namespace = "dev.spark.core.openai"
    compileSdk = 36

    defaultConfig {
        minSdk = 26
    }

    buildFeatures {
        buildConfig = true
    }
}

dependencies {
    implementation(libs.kotlinx.coroutines.android)
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.ktor.client.okhttp)
    implementation(libs.ktor.client.content.negotiation)
    implementation(libs.ktor.serialization.kotlinx.json)
    implementation(libs.ktor.client.logging)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
}

And the file that will save you a bad afternoon eighteen months from now:

// core/openai/src/main/kotlin/dev/spark/core/openai/Models.kt
package dev.spark.core.openai

/**
 * Every model ID used anywhere in this app, in one place.
 *
 * Model IDs are the fastest-rotating part of the OpenAI surface. Deprecations
 * ship with roughly three to six months of notice, and the ID is a string that
 * the compiler cannot help you with. When a model is retired, you want to fix
 * one file, not grep a codebase.
 */
object Models {
    // Text and vision
    const val FLAGSHIP = "gpt-5.6-sol"
    const val BALANCED = "gpt-5.6-terra"
    const val FAST = "gpt-5.6-luna"

    // Filled in as we go
    // const val IMAGE = "gpt-image-2"            // Chapter 6
    // const val REALTIME = "gpt-realtime-2.1"    // Chapter 10
    // const val TRANSCRIBE = "gpt-4o-mini-transcribe" // Chapter 9

    /** The model Spark uses by default. */
    const val DEFAULT = FAST
}

This looks like over-engineering for a one-screen app. It is not. In the last year alone the DALL·E snapshots were removed from the API, the Realtime beta interface was removed, and the audio snapshots were repointed. If your model IDs are scattered as string literals across fifteen feature packages, every one of those events is a treasure hunt.

Don't forget the permission, in the app module's manifest:

<uses-permission android:name="android.permission.INTERNET" />

1.4 Modeling the wire format

kotlinx.serialization maps the JSON above to Kotlin types with very little ceremony. Note the @SerialName annotations — OpenAI uses snake_case, we write camelCase, and we don't want to argue about it at every call site.

// core/openai/src/main/kotlin/dev/spark/core/openai/dto/Responses.kt
package dev.spark.core.openai.dto

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ResponseRequest(
    val model: String,
    val input: String,
    val instructions: String? = null,
    @SerialName("max_output_tokens") val maxOutputTokens: Int? = null,
    val reasoning: Reasoning? = null,
    /**
     * When true (the API default), OpenAI stores the response server-side.
     * We opt out by default and make it an explicit, per-feature choice.
     */
    val store: Boolean = false,
)

@Serializable
data class Reasoning(
    /** "none" | "low" | "medium" | "high" | "xhigh" | "max" */
    val effort: String,
)

@Serializable
data class ResponseResult(
    val id: String,
    val status: String,
    val model: String? = null,
    val output: List<OutputItem> = emptyList(),
    val usage: Usage? = null,
    val error: ApiErrorBody? = null,
    @SerialName("incomplete_details") val incompleteDetails: IncompleteDetails? = null,
)

@Serializable
data class OutputItem(
    val type: String,
    val id: String? = null,
    val role: String? = null,
    val content: List<ContentPart> = emptyList(),
)

@Serializable
data class ContentPart(
    val type: String,
    val text: String? = null,
)

@Serializable
data class Usage(
    @SerialName("input_tokens") val inputTokens: Int = 0,
    @SerialName("output_tokens") val outputTokens: Int = 0,
    @SerialName("total_tokens") val totalTokens: Int = 0,
)

@Serializable
data class IncompleteDetails(val reason: String? = null)

@Serializable
data class ApiErrorBody(
    val message: String? = null,
    val type: String? = null,
    val code: String? = null,
)

/** The convenience read that the official SDKs give you for free. */
val ResponseResult.outputText: String
    get() = output
        .filter { it.type == "message" }
        .flatMap { it.content }
        .filter { it.type == "output_text" }
        .mapNotNull { it.text }
        .joinToString(separator = "")

Two things to notice.

Every field that isn't strictly required has a default. OpenAI adds fields to responses regularly and occasionally stops sending optional ones. A DTO with no defaults is a DTO that throws MissingFieldException on a Tuesday when someone else deploys. We'll pair this with ignoreUnknownKeys = true on the JSON parser in a moment — belt and braces.

outputText is an extension property, not a method on a "response wrapper." It's the one place in the entire codebase that knows how to dig a message out of the output array, and every chapter will use it.

The domain type

We do not let DTOs leak past the module boundary. The ViewModel should never import a class called ResponseResult.

// core/openai/src/main/kotlin/dev/spark/core/openai/Completion.kt
package dev.spark.core.openai

data class Completion(
    val text: String,
    val usage: TokenUsage,
)

data class TokenUsage(
    val inputTokens: Int,
    val outputTokens: Int,
) {
    val totalTokens: Int get() = inputTokens + outputTokens
}

Why bother, in an app this small? Because in Chapter 2 the same feature will be fed by a stream rather than a single response, and in Chapter 12 by a locally-augmented prompt. If the UI layer speaks Completion, those chapters change one layer. If the UI layer speaks ResponseResult, they change everything.


1.5 Errors that mean something

Before the client, the errors. This is the part most tutorials skip, and it's the part that decides whether your app feels solid.

There are exactly five things that will go wrong, and the user needs a different response to each:

// core/openai/src/main/kotlin/dev/spark/core/openai/OpenAiError.kt
package dev.spark.core.openai

sealed class OpenAiError(message: String, cause: Throwable? = null) : Exception(message, cause) {

    /** No connectivity, DNS failure, timeout. Retryable. Offer the user a retry button. */
    class Network(cause: Throwable) : OpenAiError("Network unavailable", cause)

    /** 401/403. The key is wrong, revoked, or missing. Never retry. This is your bug, not theirs. */
    class Auth(message: String) : OpenAiError(message)

    /** 429. Rate limited or out of credit. Retryable with backoff — but only for rate limits. */
    class RateLimited(val retryAfterSeconds: Long?, message: String) : OpenAiError(message)

    /** 400/404/422. Bad model ID, malformed request, deprecated model. Never retry. */
    class InvalidRequest(val code: String?, message: String) : OpenAiError(message)

    /** 5xx, or a response with status != "completed". Retryable once or twice. */
    class Server(message: String) : OpenAiError(message)
}

The distinction that matters is retryable versus not. A 401 retried three times with exponential backoff is three guaranteed failures and a slower error message. A 429 not retried at all is a feature that randomly fails when the user is doing nothing wrong.

RateLimited carries retryAfterSeconds because OpenAI tells you how long to wait in the response headers. Honouring that number is the difference between a graceful recovery and an escalating fight with the rate limiter.


1.6 The client

// core/openai/src/main/kotlin/dev/spark/core/openai/OpenAiClient.kt
package dev.spark.core.openai

import dev.spark.core.openai.dto.ApiErrorBody
import dev.spark.core.openai.dto.Reasoning
import dev.spark.core.openai.dto.ResponseRequest
import dev.spark.core.openai.dto.ResponseResult
import dev.spark.core.openai.dto.outputText
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.JsonConvertException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

@Serializable
private data class ErrorEnvelope(val error: ApiErrorBody? = null)

@Singleton
class OpenAiClient @Inject constructor(
    private val http: HttpClient,
    private val config: OpenAiConfig,
) {

    suspend fun respond(
        prompt: String,
        instructions: String? = null,
        model: String = Models.DEFAULT,
        reasoningEffort: String = "none",
        maxOutputTokens: Int = 800,
    ): Completion = withContext(Dispatchers.IO) {

        val response: HttpResponse = try {
            http.post("${config.baseUrl}/responses") {
                contentType(ContentType.Application.Json)
                setBody(
                    ResponseRequest(
                        model = model,
                        input = prompt,
                        instructions = instructions,
                        maxOutputTokens = maxOutputTokens,
                        reasoning = Reasoning(effort = reasoningEffort),
                        store = false,
                    )
                )
            }
        } catch (e: CancellationException) {
            throw e                              // never swallow cancellation
        } catch (e: IOException) {
            throw OpenAiError.Network(e)
        }

        if (!response.status.isSuccess()) {
            throw response.toOpenAiError()
        }

        val result: ResponseResult = try {
            response.body()
        } catch (e: JsonConvertException) {
            throw OpenAiError.Server("Could not parse response: ${e.message}")
        }

        // A 200 does not mean you got what you asked for.
        if (result.status != "completed") {
            val reason = result.incompleteDetails?.reason ?: result.status
            throw OpenAiError.Server("Response did not complete: $reason")
        }

        val text = result.outputText
        if (text.isBlank()) {
            throw OpenAiError.Server("Model returned no text output")
        }

        Completion(
            text = text,
            usage = TokenUsage(
                inputTokens = result.usage?.inputTokens ?: 0,
                outputTokens = result.usage?.outputTokens ?: 0,
            ),
        )
    }

    private suspend fun HttpResponse.toOpenAiError(): OpenAiError {
        val body = runCatching { body<ErrorEnvelope>().error }.getOrNull()
        val message = body?.message ?: "HTTP ${status.value}"

        return when (status) {
            HttpStatusCode.Unauthorized,
            HttpStatusCode.Forbidden -> OpenAiError.Auth(message)

            HttpStatusCode.TooManyRequests -> OpenAiError.RateLimited(
                retryAfterSeconds = headers["retry-after"]?.toLongOrNull(),
                message = message,
            )

            HttpStatusCode.BadRequest,
            HttpStatusCode.NotFound,
            HttpStatusCode.UnprocessableEntity -> OpenAiError.InvalidRequest(body?.code, message)

            else -> OpenAiError.Server(message)
        }
    }

    private fun HttpStatusCode.isSuccess() = value in 200..299
}

The comment worth reading twice is // A 200 does not mean you got what you asked for. The Responses API returns HTTP 200 with "status": "incomplete" when it hits your max_output_tokens ceiling or a content filter. If you only check the status code, you will silently render half a sentence and never know why.

Configuration and DI

// core/openai/src/main/kotlin/dev/spark/core/openai/OpenAiConfig.kt
package dev.spark.core.openai

data class OpenAiConfig(
    val baseUrl: String,
    /**
     * Null in production. See §1.7 — the release build talks to our own backend,
     * which holds the real key. This is populated only in the dev build variant.
     */
    val devApiKey: String? = null,
)
// core/openai/src/main/kotlin/dev/spark/core/openai/di/OpenAiModule.kt
package dev.spark.core.openai.di

import dev.spark.core.openai.BuildConfig
import dev.spark.core.openai.OpenAiConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
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.header
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object OpenAiModule {

    @Provides
    @Singleton
    fun provideConfig(): OpenAiConfig = OpenAiConfig(
        baseUrl = BuildConfig.OPENAI_BASE_URL,
        devApiKey = BuildConfig.OPENAI_DEV_KEY.ifBlank { null },
    )

    @Provides
    @Singleton
    fun provideHttpClient(config: OpenAiConfig): HttpClient = HttpClient(OkHttp) {

        expectSuccess = false   // we map status codes ourselves

        install(ContentNegotiation) {
            json(
                Json {
                    ignoreUnknownKeys = true   // OpenAI adds fields; we don't crash
                    explicitNulls = false      // don't send "instructions": null
                    encodeDefaults = true
                }
            )
        }

        install(HttpTimeout) {
            // Reasoning models think. 30 seconds is not generous, it's realistic.
            requestTimeoutMillis = 60_000
            connectTimeoutMillis = 15_000
            socketTimeoutMillis = 60_000
        }

        if (BuildConfig.DEBUG) {
            install(Logging) { level = LogLevel.HEADERS }  // never LogLevel.BODY with a key in scope
        }

        defaultRequest {
            config.devApiKey?.let { header("Authorization", "Bearer $it") }
        }
    }
}

Three settings there are load-bearing:

  • ignoreUnknownKeys = true. Non-negotiable when talking to an API that ships changes weekly.
  • requestTimeoutMillis = 60_000. OkHttp's default of 10 seconds will cut off a reasoning model mid-thought and hand you a timeout that looks like a network bug. If you turn reasoning.effort up in a later chapter, raise this further.
  • LogLevel.HEADERS, never LogLevel.BODY. In debug builds with a dev key configured, BODY logging prints your Authorization header into logcat, where it lands in bug reports, screen recordings, and screenshots in Slack. I have watched a key leak exactly this way.

1.7 The API key problem

This is the most important section in the chapter, so I want to be blunt about it.

If your Android app holds an OpenAI API key, you have published that key. Not "risked publishing." Published. The APK is a zip file on a device you do not control, and a key inside it is a key inside a zip file on a device you do not control. There is no configuration of Gradle, ProGuard, or the NDK that changes this.

Let me show you rather than assert it.

Breaking it on purpose

Put your key in local.properties and pipe it through BuildConfig — the pattern half the tutorials on the internet recommend as "the secure way":

// app/build.gradle.kts — DO NOT SHIP THIS
android {
    defaultConfig {
        val key = Properties().apply {
            load(rootProject.file("local.properties").inputStream())
        }.getProperty("OPENAI_API_KEY", "")
        buildConfigField("String", "OPENAI_DEV_KEY", "\"$key\"")
    }
}

Now build a release APK with minification on and run:

./gradlew assembleRelease
unzip -o app/build/outputs/apk/release/app-release.apk -d /tmp/spark
strings /tmp/spark/classes.dex | grep -E 'sk-[A-Za-z0-9_-]{20,}'

There is your key. Full obfuscation, full minification, and the key is still sitting in the string table in plain text — because it has to be, since the app needs to send it. R8 renames classes and methods. It does not encrypt string constants that must exist at runtime.

The usual next suggestions, and why each one fails:

  • "Put it in the NDK, in C." strings libnative.so | grep sk-. Same result, one extra step.
  • "Encrypt the string and decrypt at runtime." Then the decryption key and algorithm are also in the APK. You have moved the problem one function call deeper. A determined attacker uses Frida and hooks the function that returns the decrypted key. An undetermined attacker waits for someone else to publish the hook.
  • "Nobody would bother with my little app." They are not targeting your app. They are running automated scanners across APKs on public mirrors looking for exactly this pattern, and OpenAI keys are worth money. Search GitHub for leaked keys and note how fast they get used.

The failure mode is not abstract. Someone else spends your credit, at frontier-model rates, until your card declines or your limit trips. That's why §1.1 told you to set a limit on day one.

The only architecture that works

The key lives on a server you control. The app talks to your server. Your server talks to OpenAI.

┌──────────┐   your auth   ┌───────────┐   OPENAI_API_KEY   ┌────────────┐
│  Spark   │ ────────────▶ │ your BFF  │ ─────────────────▶ │   OpenAI   │
│ (Android)│ ◀──────────── │  (proxy)  │ ◀───────────────── │            │
└──────────┘               └───────────┘                    └────────────┘

This is the backend-for-frontend pattern, and it buys you five things that have nothing to do with secrecy:

  1. Rate limiting per user. Without it, one user with a script can drain your entire budget in an afternoon. This alone justifies the server.
  2. Model switching without a release. When gpt-5.6-luna is deprecated, you change one line on the server. You do not ship an APK and wait for 40% of your users to update. Given how fast model IDs rotate, this is not a nice-to-have — it is the difference between a deprecation being an afternoon and being a crisis.
  3. Prompt iteration without a release. Same argument. Your system instructions will be wrong at launch. They always are.
  4. Cost attribution. You can see which user, which feature, which prompt.
  5. A place to put moderation. Chapter 14 leans on this heavily.

A proxy in 60 lines

You do not need a microservices platform. Here is the whole thing, in Ktor, because you already know Kotlin:

// server/src/main/kotlin/Application.kt
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.ratelimit.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlin.time.Duration.Companion.minutes

private val openAiKey: String = System.getenv("OPENAI_API_KEY")
    ?: error("OPENAI_API_KEY is not set")

fun main() {
    embeddedServer(Netty, port = System.getenv("PORT")?.toInt() ?: 8080) {

        val upstream = HttpClient(CIO)

        install(RateLimit) {
            register(RateLimitName("per-user")) {
                rateLimiter(limit = 30, refillPeriod = 1.minutes)
                requestKey { call -> call.userId() }
            }
        }

        routing {
            rateLimit(RateLimitName("per-user")) {
                post("/v1/responses") {

                    // 1. Authenticate YOUR user. Firebase Auth, Play Integrity,
                    //    your own session token — but something. An open proxy is
                    //    a leaked key with extra steps.
                    val userId = call.userId()
                        ?: return@post call.respond(HttpStatusCode.Unauthorized)

                    // 2. Forward the body, injecting the real key.
                    val body = call.receiveText()
                    val response = upstream.post("https://api.openai.com/v1/responses") {
                        header(HttpHeaders.Authorization, "Bearer $openAiKey")
                        contentType(ContentType.Application.Json)
                        setBody(body)
                    }

                    // 3. Pass status and body straight back, so the client's
                    //    error mapping in §1.5 keeps working unchanged.
                    call.respondText(
                        text = response.bodyAsText(),
                        contentType = ContentType.Application.Json,
                        status = response.status,
                    )
                }
            }
        }
    }.start(wait = true)
}

/** Replace with real verification — Firebase ID token, JWT, whatever you already use. */
private fun ApplicationCall.userId(): String? =
    request.headers["X-User-Id"]

Deploy that to any container host, set OPENAI_API_KEY as an environment variable, and point OPENAI_BASE_URL at it. Total cost: whatever your host charges for one small container, which for a hobby project is usually nothing.

The critical line is userId(). A proxy without authentication is not a proxy, it's a public API for spending your money. People will find it — endpoints get scanned — and the fact that it does not look like an OpenAI endpoint will not save you.

Note also what the proxy does not do: it doesn't parse or reshape the body. It forwards bytes and returns status codes verbatim. That means the DTOs and the error mapping we just wrote work identically against OpenAI and against your proxy, which makes the dev-mode switch below trivial.

Build variants: honest about the shortcut

For learning, hitting OpenAI directly from the emulator is fine, and setting up a server before you have written a single prompt is a great way to lose a weekend and quit. So we make the shortcut explicit and impossible to ship:

// app/build.gradle.kts
android {
    buildTypes {
        debug {
            // Straight to OpenAI, with a key from local.properties.
            // Emulator and your own device only. Never distributed.
            buildConfigField("String", "OPENAI_BASE_URL", "\"https://api.openai.com/v1\"")
            buildConfigField("String", "OPENAI_DEV_KEY", "\"${localKey()}\"")
        }
        release {
            // Through our proxy. No key in the binary — the field is empty.
            buildConfigField("String", "OPENAI_BASE_URL", "\"https://api.yourapp.com/v1\"")
            buildConfigField("String", "OPENAI_DEV_KEY", "\"\"")
            isMinifyEnabled = true
        }
    }
}

fun localKey(): String = Properties().apply {
    rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) }
}.getProperty("OPENAI_API_KEY", "")

local.properties is in .gitignore by default. Confirm that it is, in your project, right now, before you paste a key into it. Then add a guard so a mistake can't ship:

// app/build.gradle.kts
tasks.register("verifyNoKeyInRelease") {
    doLast {
        val field = android.buildTypes.getByName("release")
            .buildConfigFields["OPENAI_DEV_KEY"]?.value
        require(field == "\"\"") {
            "A dev API key is configured in the release build. Refusing to build."
        }
    }
}
tasks.named("assembleRelease") { dependsOn("verifyNoKeyInRelease") }

That check takes two minutes to write and will one day save you a key rotation, an incident review, and a very unpleasant email from your finance team.


1.8 The Compose layer

Now the easy part.

UI state

// app/src/main/kotlin/dev/spark/ui/PromptUiState.kt
package dev.spark.ui

import dev.spark.core.openai.TokenUsage

data class PromptUiState(
    val prompt: String = "",
    val result: Result = Result.Idle,
) {
    val canSubmit: Boolean
        get() = prompt.isNotBlank() && result !is Result.Loading

    sealed interface Result {
        data object Idle : Result
        data object Loading : Result
        data class Success(val text: String, val usage: TokenUsage) : Result
        data class Failure(val message: String, val retryable: Boolean) : Result
    }
}

The retryable flag on Failure is doing real work. It comes straight from the error taxonomy in §1.5 and it decides whether the UI shows a "Try again" button. Showing "Try again" next to an authentication error is a small lie that makes users tap a button that cannot possibly help.

ViewModel

// app/src/main/kotlin/dev/spark/ui/PromptViewModel.kt
package dev.spark.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.spark.core.openai.OpenAiClient
import dev.spark.core.openai.OpenAiError
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

private const val INSTRUCTIONS = """
You are Spark, a helpful assistant embedded in an Android app.
Answer clearly and concisely. Prefer plain language over jargon.
If you are uncertain, say so rather than guessing.
"""

@HiltViewModel
class PromptViewModel @Inject constructor(
    private val client: OpenAiClient,
) : ViewModel() {

    private val _state = MutableStateFlow(PromptUiState())
    val state: StateFlow<PromptUiState> = _state.asStateFlow()

    private var inFlight: Job? = null

    fun onPromptChanged(value: String) {
        _state.update { it.copy(prompt = value) }
    }

    fun onSubmit() {
        val prompt = _state.value.prompt
        if (prompt.isBlank()) return

        inFlight?.cancel()   // a second tap replaces the first request
        inFlight = viewModelScope.launch {
            _state.update { it.copy(result = PromptUiState.Result.Loading) }

            val result = runCatching {
                client.respond(prompt = prompt, instructions = INSTRUCTIONS.trim())
            }

            _state.update { current ->
                current.copy(
                    result = result.fold(
                        onSuccess = { PromptUiState.Result.Success(it.text, it.usage) },
                        onFailure = { it.toFailure() },
                    )
                )
            }
        }
    }

    fun onRetry() = onSubmit()

    private fun Throwable.toFailure() = when (this) {
        is OpenAiError.Network -> PromptUiState.Result.Failure(
            "No connection. Check your network and try again.", retryable = true
        )
        is OpenAiError.RateLimited -> PromptUiState.Result.Failure(
            retryAfterSeconds?.let { "Too many requests. Try again in $it seconds." }
                ?: "Too many requests. Try again shortly.",
            retryable = true
        )
        is OpenAiError.Server -> PromptUiState.Result.Failure(
            "The service had a problem. Try again.", retryable = true
        )
        is OpenAiError.Auth, is OpenAiError.InvalidRequest -> PromptUiState.Result.Failure(
            "Something is misconfigured in the app. Please update or contact support.",
            retryable = false
        )
        else -> PromptUiState.Result.Failure("Something went wrong.", retryable = true)
    }
}

Look at the Auth and InvalidRequest branch. The user is told "something is misconfigured in the app" — not "401 Unauthorized," and not "invalid model gpt-5.6-luna." Those are messages for you, and they belong in Crashlytics, not on a user's screen. A user cannot fix your API key. Telling them the HTTP status is just leaking your implementation and making them feel stupid.

inFlight?.cancel() matters too. Without it, double-tapping the button issues two requests, you get billed for both, and the responses can land out of order.

The screen

// app/src/main/kotlin/dev/spark/ui/PromptScreen.kt
package dev.spark.ui

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun PromptScreen(viewModel: PromptViewModel = hiltViewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    PromptContent(
        state = state,
        onPromptChanged = viewModel::onPromptChanged,
        onSubmit = viewModel::onSubmit,
        onRetry = viewModel::onRetry,
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PromptContent(
    state: PromptUiState,
    onPromptChanged: (String) -> Unit,
    onSubmit: () -> Unit,
    onRetry: () -> Unit,
) {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Spark") }) }
    ) { padding ->
        Column(
            modifier = Modifier
                .padding(padding)
                .padding(16.dp)
                .fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            OutlinedTextField(
                value = state.prompt,
                onValueChange = onPromptChanged,
                label = { Text("Ask something") },
                minLines = 3,
                modifier = Modifier.fillMaxWidth(),
            )

            Button(
                onClick = onSubmit,
                enabled = state.canSubmit,
                modifier = Modifier.align(Alignment.End),
            ) {
                Text("Send")
            }

            HorizontalDivider()

            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(1f),
                contentAlignment = Alignment.TopStart,
            ) {
                when (val result = state.result) {
                    PromptUiState.Result.Idle -> EmptyHint()
                    PromptUiState.Result.Loading -> LoadingIndicator()
                    is PromptUiState.Result.Success -> SuccessBody(result)
                    is PromptUiState.Result.Failure -> FailureBody(result, onRetry)
                }
            }
        }
    }
}

@Composable
private fun EmptyHint() {
    Text(
        text = "Type a prompt above and tap Send.",
        style = MaterialTheme.typography.bodyMedium,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
    )
}

@Composable
private fun LoadingIndicator() {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        Spacer(Modifier.height(24.dp))
        CircularProgressIndicator()
        Text(
            "Thinking…",
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

@Composable
private fun SuccessBody(result: PromptUiState.Result.Success) {
    Column(
        modifier = Modifier.verticalScroll(rememberScrollState()),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Text(
            text = result.text,
            style = MaterialTheme.typography.bodyLarge,
        )
        Text(
            text = "${result.usage.inputTokens} in · " +
                "${result.usage.outputTokens} out · " +
                "≈ ${'$'}${"%.5f".format(estimateCostUsd(result.usage))}",
            style = MaterialTheme.typography.labelSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

@Composable
private fun FailureBody(result: PromptUiState.Result.Failure, onRetry: () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Spacer(Modifier.height(24.dp))
        Text(
            text = result.message,
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.error,
            textAlign = TextAlign.Center,
        )
        if (result.retryable) {
            OutlinedButton(onClick = onRetry) { Text("Try again") }
        }
    }
}

Notice that PromptContent takes state and lambdas and knows nothing about the ViewModel — so it previews and tests without Hilt, without a network, and without a key. Every screen in this book follows that split.


1.9 Two things you should decide, not inherit

The token counter in the corner

That little 29 in · 17 out · ≈ $0.00007 line under the response is not decoration. It is a habit.

// app/src/main/kotlin/dev/spark/ui/Cost.kt
package dev.spark.ui

import dev.spark.core.openai.TokenUsage

// Prices are per million tokens, for gpt-5.6-luna at time of writing.
// Keep these next to Models.kt and update them together.
private const val INPUT_PER_MTOK = 1.00
private const val OUTPUT_PER_MTOK = 6.00

fun estimateCostUsd(usage: TokenUsage): Double =
    (usage.inputTokens / 1_000_000.0) * INPUT_PER_MTOK +
        (usage.outputTokens / 1_000_000.0) * OUTPUT_PER_MTOK

Ship this in your debug builds for every AI feature you ever write. When you can see that your carefully engineered 900-token system prompt costs more than the actual answer, you shorten it. When you cannot see that, you don't. The developers who end up with runaway inference bills are, almost without exception, the ones who never looked at usage until the invoice arrived.

Note that this estimate ignores cached input tokens, which are billed at a discount — Chapter 14 makes the meter accurate. For now, an approximation you look at daily beats a precise number you look at monthly.

store = false

I set store = false in the request DTO's default, which means our responses are not retained on OpenAI's servers for later chaining. That was a deliberate choice, and you should make it deliberately too rather than accepting the API default.

Storing responses gives you server-side conversation state — you can pass previous_response_id instead of resending the whole history, which saves tokens and simplifies multi-turn code. That's genuinely useful, and Chapter 2 discusses it properly when we build a real conversation.

But it also means user prompts sit in OpenAI's infrastructure by default. If your app touches anything sensitive — health, finance, children's data, anything covered by a data protection regime your users live under — that is a decision you want to have made on purpose and be able to defend, not a default you inherited from a code sample. Read OpenAI's data usage terms for the API, and if you are shipping into the EU, read them again with your DPA in hand.

Defaulting to false and opting in per-feature is the posture I'd recommend. It costs you nothing until you need it.


1.10 Running it

Build the debug variant with your key in local.properties, install, and ask it something.

If it works, immediately do the destructive experiment from §1.7. Build the release APK, grep the dex for your key, and watch it fall out. Do it once, with your own hands. Reading that a key can be extracted from an APK is information; watching your own key print to your own terminal is a lesson, and it will change how you build every AI feature for the rest of your career.

When it doesn't work

Symptom Cause Fix
401 Unauthorized Key missing, wrong project, or you copied it with a trailing newline Print the key length in debug. Regenerate if unsure.
404 model_not_found Model ID typo, or the model was deprecated and removed Check the models page; fix Models.kt. This is the argument for centralizing IDs.
429 immediately, on a fresh account No credit on the account, or tier-1 rate limits Add credit; check your usage tier.
Timeout after 10 seconds Default OkHttp timeout, not a network problem Raise HttpTimeout — see §1.6.
MissingFieldException A DTO field without a default Add defaults; confirm ignoreUnknownKeys = true.
Blank result, HTTP 200 status: "incomplete" — usually max_output_tokens Raise the ceiling; check incompleteDetails.reason.
Works on emulator, fails on device Cleartext or DNS on your proxy Check OPENAI_BASE_URL is https and reachable.

1.11 Exercises

  1. Add a model picker. Put a SegmentedButton row above the prompt field: Fast, Balanced, Flagship. Send the same prompt to each and record the latency and cost from your token meter. You will very likely find that luna is enough for whatever you first assumed needed sol.

  2. Add a reasoning-effort dial. Expose none, low, medium. Ask the same non-trivial question at each level and compare the output token counts. This is the fastest way to build an intuition for what reasoning actually costs on mobile.

  3. Break it, then fix it. Turn off airplane mode mid-request. Point baseUrl at a URL that returns a 500. Set maxOutputTokens to 10 and watch status come back as incomplete. Every one of these should produce a distinct, human-readable message and a correctly-present-or-absent retry button. If any of them shows a stack trace or a generic "error," your error mapping has a hole.

  4. Deploy the proxy. Take the 60-line Ktor server, put it on any free-tier container host, add real authentication, and repoint the release build at it. Then rerun the strings | grep attack on the release APK and confirm there is nothing to find. Do not skip this exercise. Everything from Chapter 6 onward assumes it.


1.12 What you built

Spark is one screen. What sits behind that screen is:

  • A :core:openai module with typed DTOs, a correct output parser that survives reasoning items, and an error taxonomy that distinguishes retryable from fatal.
  • A single file holding every model ID, so the next deprecation costs you five minutes rather than an afternoon of grep.
  • A UI state model with four honest states, and a retry button that only appears when retrying could actually help.
  • A cost meter, running from day one.
  • A key that is not in your APK, and a build-time check that keeps it that way.

None of that is glamorous, and none of it is what you'll show anyone. But every chapter after this one — image generation, voice, realtime, retrieval — is a new capability bolted onto this same skeleton. If the skeleton is sound, the rest is fun. If it isn't, you will rebuild it under deadline, in production, with a leaked key and an angry invoice.

Next: Chapter 2, Streaming Responses. The respond() function we just wrote makes the user stare at a spinner for four seconds. We're going to make the first word appear in four hundred milliseconds, using Server-Sent Events, a Flow<String>, and Compose's collectAsStateWithLifecycle — plus the cancellation handling that everybody forgets and that costs real money.