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 aFlow<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:
recentHistorysends 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
- New Empty Activity project
AiChat, packagecom.example.aichat. - Add Room, KSP, OkHttp, serialization and the coroutines test library from the Project 19 block.
- Create the files below. Start with
FakeAiService. - When your own server exists, switch to
ServerAiServiceinMainActivity.
`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
sendstores the user's message, builds the recent history and starts collecting the reply flow on the IO dispatcher.- Every emitted text replaces the
streamingstate, 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.
ServerAiServicereads 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
- Build the app with the fake service and chat with it.
- Write the server contract for your own backend: input messages, output streamed text.
- Add a system prompt on the server (not in the app) that sets the assistant's role.
- 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.