n4nAI

Building an Android chat app with n4n and Jetpack Compose

Step-by-step guide to building an Android chat app with n4n and Jetpack Compose: OpenAI-compatible streaming, Kotlin retrofit, and Compose UI.

n4n Team2 min read518 words

Audio narration

Coming soon — every post will get a voice note here.

Building an android chat app n4n jetpack compose stack is straightforward if you treat the LLM gateway as a standard OpenAI-compatible backend. This tutorial ships a minimal but production-shaped Kotlin app that streams chat completions into a Compose UI, handling token increments without blocking the main thread.

Prerequisites

  • Android Studio Hedgehog (2023.1.1) or newer, with a project targeting minSdk 24 and Kotlin 1.9.
  • A key from n4n.ai. Its OpenAI-compatible endpoint fronts 240+ models and automatically falls back when a provider is rate-limited or degraded, so you write one client.
  • Compose BOM 2024.01.00, Retrofit 2.11, OkHttp 4.12, kotlinx-serialization 1.6.
  • Working knowledge of ViewModel, mutableStateOf, and Kotlin coroutines.

Step 1: Declare dependencies

Retrofit handles non-streaming calls; OkHttp handles Server-Sent Events (SSE) directly because Retrofit’s streaming support is awkward for line-delimited JSON. Add these to app/build.gradle.kts:

dependencies {
    implementation(platform("androidx.compose:compose-bom:2024.01.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
}

Step 2: Model the OpenAI-compatible contract

The request shape matches the OpenAI chat completions spec. We only need the streaming delta path.

@Serializable
data class ChatMessage(val role: String, val content: String)

@Serializable
data class ChatRequest(
    val model: String,
    val messages: List<ChatMessage>,
    val stream: Boolean = true
)

@Serializable
data class ChatChoiceDelta(val delta: ChatMessage? = null)

@Serializable
data class StreamChunk(val choices: List<ChatChoiceDelta>? = null)

Keep role as "user", "assistant", or "system". The gateway forwards these verbatim.

Step 3: Streaming client with OkHttp

We open a plain POST and read the response body as a buffered source. SSE frames arrive as data: {json}\n. Parse each line, skip heartbeats, and break on [DONE].

class StreamingClient(private val apiKey: String) {
    private val http = OkHttpClient.Builder()
        .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
        .build()

    fun stream(
        model: String,
        messages: List<ChatMessage>,
        onToken: (String) -> Unit,
        onDone: () -> Unit
    ) {
        val reqBody = Json.encodeToString(ChatRequest(model, messages, stream = true))
        val request = Request.Builder()
            .url("https://api.n4n.ai/v1/chat/completions")
            .post(reqBody.toRequestBody("application/json".toMediaType()))
            .addHeader("Authorization", "Bearer $apiKey")
            .build()

        http.newCall(request).enqueue(object : okhttp3.Callback {
            override fun onFailure(call: okhttp3.Call, e: IOException) = onDone()
            override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
                response.body?.source()?.let { src ->
                    while (!src.exhausted()) {
                        val line = src.readUtf8Line() ?: break
                        if (!line.startsWith("data:")) continue
                        val data = line.removePrefix("data:").trim()
                        if (data == "[DONE]") break
                        runCatching {
                            val chunk = Json.decodeFromString<StreamChunk>(data)
                            chunk.choices?.firstOrNull()?.delta?.content?.let(onToken)
                        }
                    }
                }
                onDone()
            }
        })
    }
}

Step 4: ViewModel state

The UI state is a list of ChatMessage, an input string, and a busy flag. We append an empty assistant message as a placeholder, then mutate it in place as tokens arrive.

class ChatViewModel(private val client: StreamingClient) : ViewModel() {
    var messages by mutableStateOf(listOf<ChatMessage>())
        private set
    var input by mutableStateOf("")
        private set
    var busy by mutableStateOf(false)
        private set

    fun updateInput(s: String) { input = s }

    fun send() {
        if (input.isBlank() || busy) return
        val userMsg = ChatMessage("user", input)
        val history = messages + userMsg
        messages = history + ChatMessage("assistant", "")
        val payload = history // exclude empty assistant placeholder
        input = ""
        busy = true
        client.stream(
            model = "openai/gpt-4o-mini",
            messages = payload,
            onToken = { token ->
                messages = messages.toMutableList().apply {
                    val last = last()
                    set(size - 1, ChatMessage("assistant", last.content + token))
                }
            },
            onDone = { busy = false }
        )
    }
}

Step 5: Compose UI

A LazyColumn renders bubbles; a Row at the bottom holds the input and send button. Alignment flips based on role.

@Composable
fun ChatScreen(vm: ChatViewModel = viewModel()) {
    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        LazyColumn(modifier = Modifier.weight(1f)) {
            items(vm.messages) { msg ->
                val alignment = if (msg.role == "user") Alignment.End else Alignment.Start
                Box(modifier = Modifier.fillMaxWidth().padding(4.dp), contentAlignment = alignment) {
                    Surface(
                        color = if (msg.role == "user")
                            MaterialTheme.colorScheme.primary
                        else
                            MaterialTheme.colorScheme.surfaceVariant,
                        shape = MaterialTheme.shapes.medium
                    ) {
                        Text(msg.content.ifBlank { "…" }, modifier = Modifier.padding(10.dp))
                    }
                }
            }
        }
        Row(modifier = Modifier.fillMaxWidth()) {
            TextField(
                value = vm.input,
                onValueChange = vm::updateInput,
                modifier = Modifier.weight(1f),
                placeholder = { Text("Message") }
            )
            Spacer(Modifier.width(8.dp))
            Button(onClick = vm::send, enabled = !vm.busy && vm.input.isNotBlank()) {
                Text("Send")
            }
        }
    }
}

Step 6: Wire into Activity

Inject the client and provide the ViewModel via a factory. Store the key in BuildConfig.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val client = StreamingClient(BuildConfig.N4N_KEY)
        setContent {
            MaterialTheme {
                ChatScreen(viewModel(factory = object : ViewModelProvider.Factory {
                    override fun <T : ViewModel> create(modelClass: Class<T>): T =
                        ChatViewModel(client) as T
                }))
            }
        }
    }
}

Checkpoint: expected runtime output

Send “Hello”. Logcat shows raw SSE frames:

data: {"choices":[{"delta":{"content":"Hi"}}]}
data: {"choices":[{"delta":{"content":" there"}}]}
data: [DONE]

The UI renders a right-aligned user bubble “Hello”, then a left-aligned assistant bubble that grows from “…” to “Hi there” token by token. The Send button stays disabled until busy flips false.

Extending with a system prompt

Prepend a system message before the user turn in send():

val system = ChatMessage("system", "You are a concise Android helper.")
val payload = listOf(system) + history

The android chat app n4n jetpack compose flow requires no other changes; the gateway routes the same schema.

Pitfalls and fixes

  • History pollution: The empty assistant placeholder must be dropped before sending, or the model receives a malformed turn. We build payload from history only.
  • Thread safety: OkHttp invokes callbacks on its worker pool. Compose’s mutableStateOf writes are safe from any thread, but if you later move parsing into viewModelScope use Dispatchers.IO.
  • Key exposure: Never hardcode the key. Read local.properties in Gradle and expose via buildConfigField.
  • Backpressure: For very fast streams, batch token updates with a short debounce if you see UI jank. The current naive append is fine for moderate token rates.

Why this architecture holds up

The android chat app n4n jetpack compose split keeps networking isolated from rendering. Swapping models is a one-line string change. Because the endpoint speaks standard OpenAI format, you can point the same StreamingClient at any compliant gateway during local testing. The UI stays declarative, the ViewModel stays testable, and the OkHttp layer can be swapped for WebSocket if you later need bidirectional traffic.

Tagsandroidn4njetpack-composechat-app

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All kotlin & android llm integration posts →