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 24and 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
payloadfromhistoryonly. - Thread safety: OkHttp invokes callbacks on its worker pool. Compose’s
mutableStateOfwrites are safe from any thread, but if you later move parsing intoviewModelScopeuseDispatchers.IO. - Key exposure: Never hardcode the key. Read
local.propertiesin Gradle and expose viabuildConfigField. - 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.