Project 19: AI Chatbot App with Streaming Replies

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 20 min read · Updated

Stored messages plus a live one

  1. The user's message is saved and the recent history is prepared.
  2. The service returns a Flow that emits the reply as it grows.
  3. Each emission updates the live message on screen.
  4. When it finishes, or the user stops it, the text is saved to Room.

AI chat is now a normal app feature. The user types a question and the answer appears word by word. Building the screen is mostly about state: a list of stored messages, plus one message that is still being written. Building it safely is about design: the AI key must stay on a server, history must be limited, and the user must be able to stop a long answer.

To let you build and test the whole app without a key or a server, the project uses two classes behind one interface. FakeAiService creates a pretend answer slowly, and ServerAiService calls your own backend. You start with the fake one, and switch to the real one when your server is ready.

Concepts in this project

  • Interface with two implementations: AiService.reply(history) returns a Flow<String> that emits the growing text.
  • Streaming state: stored messages come from Room. The partial reply lives in a separate StateFlow, and is saved only when it is complete (or when the user stops it).
  • Cancellation: the Stop button cancels the coroutine job. The code catches CancellationException, saves what was written and rethrows it.
  • History window: recentHistory sends only the last messages, because the model has no memory and long requests cost more.
  • Server-side key: the app sends messages to your server. The server adds the secret key, checks the user, limits use and calls the model.
  • Honest UI: a notice tells users that AI answers can be wrong.

Create the project

  1. New Empty Activity project AiChat, package com.example.aichat.
  2. Add Room, KSP, OkHttp, serialization and the coroutines test library from the Project 19 block.
  3. Create the files below. Start with FakeAiService.
  4. When your own server exists, switch to ServerAiService in MainActivity.

`Gradle dependencies for projects 11 to 21 (use the block for this project)`

// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")

// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
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.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.

// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.

// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.

// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")

// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")

// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 21 Job portal
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.7.3")
AiChat/app/src/main/java/com/example/aichat/
  ChatModels.kt        stored and wire messages, history window
  ChatDb.kt            Room DAO and database
  AiService.kt         interface, FakeAiService, ServerAiService
  AiChatViewModel.kt   send, stop, streaming state
  AiChatScreen.kt      messages, input, stop button
  MainActivity.kt
app/src/test/.../AiChatTest.kt

The code

`app/src/main/java/com/example/aichat/ChatModels.kt`

package com.example.aichat

import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable

@Entity(tableName = "chat_message")
data class StoredMessage(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val role: String,          // "user" or "assistant"
    val text: String,
    val time: Long = System.currentTimeMillis(),
)

// What your server expects. The server holds the AI key, so the app never has it.
@Serializable
data class WireMessage(val role: String, val text: String)

@Serializable
data class ChatRequest(val messages: List<WireMessage>)

// Only the most recent messages are sent, to limit cost and keep requests small.
fun recentHistory(all: List<StoredMessage>, max: Int = 12): List<WireMessage> {
    return all.takeLast(max).map { WireMessage(it.role, it.text) }
}

`app/src/main/java/com/example/aichat/ChatDb.kt`

package com.example.aichat

import androidx.room.Dao
import androidx.room.Database
import androidx.room.Insert
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface MessageDao {
    @Query("SELECT * FROM chat_message ORDER BY id")
    fun all(): Flow<List<StoredMessage>>

    @Insert
    suspend fun insert(message: StoredMessage): Long

    @Update
    suspend fun update(message: StoredMessage)

    @Query("DELETE FROM chat_message")
    suspend fun clear()
}

@Database(entities = [StoredMessage::class], version = 1, exportSchema = false)
abstract class ChatDatabase : RoomDatabase() {
    abstract fun messages(): MessageDao
}

`app/src/main/java/com/example/aichat/AiService.kt`

package com.example.aichat

import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody

// One interface, two implementations. The screen does not care which one it gets.
interface AiService {
    // Emits the reply as it grows: "Hel", "Hello", "Hello the"...
    fun reply(history: List<WireMessage>): Flow<String>
}

// Works with no server and no key, so you can build and test the whole screen first.
class FakeAiService : AiService {
    override fun reply(history: List<WireMessage>): Flow<String> = flow {
        val question = history.lastOrNull { it.role == "user" }?.text.orEmpty()
        val answer = "This is a demo answer to: " + question + ". Connect the app to your own server to get real AI replies."
        var shown = ""
        for (word in answer.split(" ")) {
            shown = if (shown.isEmpty()) word else shown + " " + word
            emit(shown)
            delay(80)
        }
    }
}

// Calls YOUR server. The server checks the user, holds the AI key, calls the model and
// streams plain text chunks back. Replace the URL with your own.
class ServerAiService(
    private val client: OkHttpClient,
    private val url: String,
) : AiService {
    private val json = Json

    override fun reply(history: List<WireMessage>): Flow<String> = flow {
        val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(history))
            .toRequestBody("application/json".toMediaType())
        val request = Request.Builder().url(url).post(body).build()
        client.newCall(request).execute().use { response ->
            if (!response.isSuccessful) throw java.io.IOException("Server error " + response.code)
            val source = response.body?.source() ?: throw java.io.IOException("Empty reply")
            var text = ""
            while (!source.exhausted()) {
                val chunk = source.readUtf8Line() ?: break
                text += chunk
                emit(text)
            }
        }
    }
}

`app/src/main/java/com/example/aichat/AiChatViewModel.kt`

package com.example.aichat

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class AiChatViewModel(
    private val dao: MessageDao,
    private val ai: AiService,
) : ViewModel() {

    val messages: StateFlow<List<StoredMessage>> = dao.all()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    // text of the reply being written right now, shown below the stored messages
    private val _streaming = MutableStateFlow<String?>(null)
    val streaming: StateFlow<String?> = _streaming.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    private var job: Job? = null

    fun send(text: String) {
        val clean = text.trim().take(2000)
        if (clean.isEmpty() || job?.isActive == true) return
        _error.value = null
        job = viewModelScope.launch {
            dao.insert(StoredMessage(role = "user", text = clean))
            val history = recentHistory(dao.all().first())
            var last = ""
            try {
                ai.reply(history).flowOn(Dispatchers.IO).collect {
                    last = it
                    _streaming.value = it
                }
                if (last.isNotEmpty()) dao.insert(StoredMessage(role = "assistant", text = last))
            } catch (e: CancellationException) {
                // the user pressed Stop: keep what was written so far
                if (last.isNotEmpty()) dao.insert(StoredMessage(role = "assistant", text = last))
                throw e
            } catch (e: Exception) {
                _error.value = "Could not get a reply. Try again."
            } finally {
                _streaming.value = null
            }
        }
    }

    fun stop() {
        job?.cancel()
    }

    fun clear() {
        viewModelScope.launch { dao.clear() }
    }
}

`app/src/main/java/com/example/aichat/AiChatScreen.kt`

package com.example.aichat

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun AiChatScreen(vm: AiChatViewModel) {
    val messages by vm.messages.collectAsState()
    val streaming by vm.streaming.collectAsState()
    val error by vm.error.collectAsState()
    var draft by remember { mutableStateOf("") }
    val list = rememberLazyListState()

    LaunchedEffect(messages.size, streaming) {
        val count = messages.size + if (streaming != null) 1 else 0
        if (count > 0) list.animateScrollToItem(count - 1)
    }

    Column(Modifier.fillMaxSize()) {
        Row(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
            Column {
                Text("Assistant", style = MaterialTheme.typography.titleLarge)
                Text("AI answers can be wrong. Check important facts.", style = MaterialTheme.typography.bodySmall)
            }
            TextButton(onClick = vm::clear) { Text("Clear") }
        }
        LazyColumn(
            state = list,
            modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
            verticalArrangement = Arrangement.spacedBy(6.dp),
        ) {
            items(messages, key = { it.id }) { Bubble(it.text, mine = it.role == "user") }
            streaming?.let { item { Bubble(it + " ▌", mine = false) } }
        }
        error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 12.dp)) }
        Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
            OutlinedTextField(
                value = draft,
                onValueChange = { draft = it },
                placeholder = { Text("Ask something") },
                modifier = Modifier.weight(1f),
            )
            if (streaming != null) {
                Button(onClick = vm::stop, modifier = Modifier.padding(start = 8.dp)) { Text("Stop") }
            } else {
                Button(
                    onClick = {
                        vm.send(draft)
                        draft = ""
                    },
                    modifier = Modifier.padding(start = 8.dp),
                ) { Text("Send") }
            }
        }
    }
}

@Composable
private fun Bubble(text: String, mine: Boolean) {
    Box(Modifier.fillMaxWidth(), contentAlignment = if (mine) Alignment.CenterEnd else Alignment.CenterStart) {
        Surface(
            shape = RoundedCornerShape(14.dp),
            color = if (mine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
        ) {
            Text(text, Modifier.padding(12.dp))
        }
    }
}

`app/src/main/java/com/example/aichat/MainActivity.kt`

package com.example.aichat

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.room.Room

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val db = Room.databaseBuilder(applicationContext, ChatDatabase::class.java, "aichat.db").build()
        // Start with the fake service. To use a real model, call your own server:
        // ServerAiService(OkHttpClient(), "https://your-server.example.com/chat")
        val ai: AiService = FakeAiService()

        setContent {
            MaterialTheme {
                Surface(Modifier.systemBarsPadding()) {
                    val vm: AiChatViewModel = viewModel(
                        factory = object : ViewModelProvider.Factory {
                            @Suppress("UNCHECKED_CAST")
                            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                                return AiChatViewModel(db.messages(), ai) as T
                            }
                        },
                    )
                    AiChatScreen(vm)
                }
            }
        }
    }
}

`app/src/test/java/com/example/aichat/AiChatTest.kt`

package com.example.aichat

import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class AiChatTest {
    @Test
    fun historyKeepsOnlyRecentMessages() {
        val all = (1..30).map { StoredMessage(it.toLong(), "user", "m" + it) }
        val recent = recentHistory(all, max = 5)
        assertEquals(5, recent.size)
        assertEquals("m30", recent.last().text)
    }

    @Test
    fun fakeReplyGrowsWordByWord() = runTest {
        val chunks = FakeAiService().reply(listOf(WireMessage("user", "Hi"))).toList()
        assertTrue(chunks.size > 3)
        assertTrue(chunks.last().startsWith(chunks.first()))
    }
}

How it works

  • send stores the user's message, builds the recent history and starts collecting the reply flow on the IO dispatcher.
  • Every emitted text replaces the streaming state, so the screen shows the growing answer with a cursor mark.
  • When the flow finishes, the full answer is stored as an assistant message and the streaming state is cleared.
  • If the request fails, an error line appears and the app stays usable. The Send button turns into Stop while a reply is running.
  • ServerAiService reads the response body line by line and emits the growing text. Your server decides how it streams.

Expected output

The screen shows the title Assistant, the notice "AI answers can be wrong. Check important facts." and an input box. Type "What is Kotlin?" and press Send: your message appears on the right. Below it an answer begins to appear on the left word by word, ending with "Connect the app to your own server to get real AI replies." while the Send button shows Stop. Press Stop in the middle: the partial text stays as a message. Close and reopen the app: the conversation is still there. Clear removes it.

Your task

  1. Build the app with the fake service and chat with it.
  2. Write the server contract for your own backend: input messages, output streamed text.
  3. Add a system prompt on the server (not in the app) that sets the assistant's role.
  4. Add a daily message limit on the server and show a friendly message when it is reached.

How to check it worked

  • No AI key exists anywhere in the app or repository.
  • Stop keeps the partial answer.
  • Only recent messages are sent.

Frequently asked questions

Why use a fake service?

It lets you build and test the screen, streaming, stopping and history without a key, a server or any cost.

Why send only recent messages?

The model has no memory, so you send context each time. Long histories cost more and can exceed limits, so send a recent window or a summary.

How do I stop a reply?

Cancel the coroutine job that collects the flow. Save the partial text in the cancellation handler and rethrow the exception.