On this page
A chatbot looks simple: a text box and a list of bubbles. The hard parts are hidden. Streaming text into a bubble without janking the list, keeping conversation context without sending an ever-growing history, letting users stop a runaway answer, and failing gracefully on a flaky mobile connection.
In this project you'll build an AI chat assistant for a fictional "Android Study Buddy" app, using Kotlin, Jetpack Compose and Gemini through Firebase AI Logic. It assumes you've completed the setup from Gemini API Integration in Android Using Kotlin (Firebase project, firebase-ai dependency, App Check).
What we'll build
- A conversation screen with user and assistant bubbles, auto-scroll, and an "AI-generated" label.
- Token-by-token streaming of the assistant's reply, with a Stop button.
- Error bubbles with Retry that resends the failed message.
- A system instruction that keeps the assistant on-topic, and history trimming to control cost.
Project structure
app/src/main/java/com/example/studybuddy/chat/
βββ ChatMessage.kt β UI/domain model
βββ ChatRepository.kt β wraps Firebase AI Logic chat, trims history
βββ ChatViewModel.kt β state, streaming, stop, retry
βββ ChatScreen.kt β Compose UI: list, bubbles, input barStep 1: The message model
import java.util.UUID
enum class Author { USER, ASSISTANT }
enum class Status { STREAMING, DONE, FAILED }
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val author: Author,
val text: String,
val status: Status = Status.DONE,
)
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
val input: String = "",
) {
val isResponding: Boolean get() = messages.lastOrNull()?.status == Status.STREAMING
}Messages are immutable, and every message has a stable id. That id becomes the LazyColumn key, so Compose can update the streaming bubble in place instead of rebuilding the list.
Step 2: The repository
Gemini's chat API keeps conversation context by resending history with each turn, and every resent token is billed as input. Instead of holding one ever-growing chat session, the repository rebuilds a chat from the last N completed messages on every send. It's simple, predictable and cheap.
import com.google.firebase.Firebase
import com.google.firebase.ai.ai
import com.google.firebase.ai.type.GenerativeBackend
import com.google.firebase.ai.type.content
import com.google.firebase.ai.type.generationConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapNotNull
interface ChatRepository {
/** Streams the assistant's reply to [message], given previous completed turns. */
fun reply(history: List<ChatMessage>, message: String): Flow<String>
}
class GeminiChatRepository(
modelName: String = "gemini-3.8-flash", // keep in remote config in production
private val maxHistoryMessages: Int = 12, // ~6 user/assistant exchanges
) : ChatRepository {
private val model = Firebase.ai(backend = GenerativeBackend.googleAI()).generativeModel(
modelName = modelName,
generationConfig = generationConfig {
temperature = 0.6f
maxOutputTokens = 1024
},
systemInstruction = content {
text(
"""
You are Study Buddy, a friendly tutor for Android development with Kotlin and Jetpack Compose.
- Keep answers under 200 words unless the user asks for more detail.
- Use short Kotlin snippets when they help; say when code is untested.
- If a question is unrelated to programming, politely steer back to Android topics.
- If you are not sure, say so instead of guessing.
""".trimIndent()
)
},
)
override fun reply(history: List<ChatMessage>, message: String): Flow<String> {
val recent = history
.filter { it.status == Status.DONE && it.text.isNotBlank() } // skip failed/partial turns
.takeLast(maxHistoryMessages)
.map { msg ->
content(role = if (msg.author == Author.USER) "user" else "model") { text(msg.text) }
}
val chat = model.startChat(history = recent)
return chat.sendMessageStream(message).mapNotNull { it.text }
}
}If a reply failed halfway, sending that partial text back as "what the model said" confuses the next answer. Only completed exchanges become context.
Step 3: The ViewModel
class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private var replyJob: Job? = null
fun onInputChange(text: String) = _uiState.update { it.copy(input = text.take(2_000)) }
fun send() {
val text = _uiState.value.input.trim()
if (text.isEmpty() || _uiState.value.isResponding) return
_uiState.update { it.copy(input = "") }
startReply(userText = text, appendUser = true)
}
/** Re-sends the user message that preceded a failed assistant reply. */
fun retry(failedId: String) {
val messages = _uiState.value.messages
val index = messages.indexOfFirst { it.id == failedId }
val userText = messages.getOrNull(index - 1)?.takeIf { it.author == Author.USER }?.text ?: return
_uiState.update { it.copy(messages = messages.filterNot { m -> m.id == failedId }) }
startReply(userText = userText, appendUser = false)
}
fun stop() { replyJob?.cancel() }
private fun startReply(userText: String, appendUser: Boolean) {
val history = _uiState.value.messages.let { if (appendUser) it else it.dropLast(1) }
val assistant = ChatMessage(author = Author.ASSISTANT, text = "", status = Status.STREAMING)
_uiState.update { state ->
val withUser = if (appendUser) state.messages + ChatMessage(author = Author.USER, text = userText) else state.messages
state.copy(messages = withUser + assistant)
}
replyJob = viewModelScope.launch {
try {
repository.reply(history, userText).collect { chunk ->
updateMessage(assistant.id) { it.copy(text = it.text + chunk) }
}
updateMessage(assistant.id) { it.copy(status = Status.DONE) }
} catch (e: CancellationException) {
// Stopped by the user: keep what we have, mark as done.
updateMessage(assistant.id) { it.copy(status = Status.DONE, text = it.text.ifBlank { "(stopped)" }) }
throw e
} catch (e: Exception) {
updateMessage(assistant.id) { it.copy(status = Status.FAILED, text = e.toChatError()) }
}
}
}
private fun updateMessage(id: String, transform: (ChatMessage) -> ChatMessage) =
_uiState.update { state -> state.copy(messages = state.messages.map { if (it.id == id) transform(it) else it }) }
}
private fun Throwable.toChatError(): String = when (this) {
is java.io.IOException -> "You're offline. Check your connection."
else -> "Study Buddy couldn't answer that. Tap retry."
}The streaming logic never touches the UI. It only updates one message by id. Because retry removes the failed bubble and reuses the preceding user text, the history stays clean.
Step 4: The Compose UI
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
// Keep the newest message visible as it grows.
val lastText = state.messages.lastOrNull()?.text
LaunchedEffect(state.messages.size, lastText) {
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex)
}
Scaffold(
topBar = { TopAppBar(title = { Text("Study Buddy") }) },
bottomBar = {
ChatInputBar(
text = state.input,
isResponding = state.isResponding,
onTextChange = viewModel::onInputChange,
onSend = viewModel::send,
onStop = viewModel::stop,
)
},
) { padding ->
if (state.messages.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text("Ask anything about Kotlin or Compose.", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
state = listState,
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize().padding(padding),
) {
items(state.messages, key = { it.id }) { message ->
MessageBubble(message = message, onRetry = { viewModel.retry(message.id) })
}
}
}
}
}
@Composable
private fun MessageBubble(message: ChatMessage, onRetry: () -> Unit) {
val isUser = message.author == Author.USER
Column(
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
modifier = Modifier.fillMaxWidth(),
) {
Surface(
color = when {
message.status == Status.FAILED -> MaterialTheme.colorScheme.errorContainer
isUser -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
},
shape = RoundedCornerShape(
topStart = 16.dp, topEnd = 16.dp,
bottomStart = if (isUser) 16.dp else 4.dp,
bottomEnd = if (isUser) 4.dp else 16.dp,
),
modifier = Modifier.widthIn(max = 320.dp),
) {
Column(Modifier.padding(12.dp)) {
if (message.status == Status.STREAMING && message.text.isEmpty()) {
Text("Thinkingβ¦", style = MaterialTheme.typography.bodyMedium)
} else {
SelectionContainer { Text(message.text, style = MaterialTheme.typography.bodyLarge) }
}
}
}
if (!isUser) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("AI-generated", style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 4.dp, top = 2.dp))
if (message.status == Status.FAILED) TextButton(onClick = onRetry) { Text("Retry") }
}
}
}
}
@Composable
private fun ChatInputBar(
text: String,
isResponding: Boolean,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
onStop: () -> Unit,
) {
Surface(tonalElevation = 3.dp) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().navigationBarsPadding().imePadding().padding(8.dp),
) {
OutlinedTextField(
value = text,
onValueChange = onTextChange,
placeholder = { Text("Message Study Buddy") },
maxLines = 4,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
if (isResponding) {
FilledTonalButton(onClick = onStop) { Text("Stop") }
} else {
Button(onClick = onSend, enabled = text.isNotBlank()) { Text("Send") }
}
}
}
}Create the ViewModel with your DI framework (for example Hilt), or a viewModel { ChatViewModel(GeminiChatRepository()) } initializer, and call ChatScreen(viewModel) from your setContent or navigation graph. In the Activity, enable edge-to-edge so imePadding() moves the input bar above the keyboard.
Step 5: Test the ViewModel with a fake repository
class ChatViewModelTest {
@get:Rule val mainRule = MainDispatcherRule() // sets Dispatchers.Main to a test dispatcher
private class FakeRepo(private val chunks: List<String>, private val fail: Boolean = false) : ChatRepository {
override fun reply(history: List<ChatMessage>, message: String) = flow {
chunks.forEach { emit(it) }
if (fail) throw java.io.IOException("offline")
}
}
@Test
fun `streamed chunks are joined into one assistant message`() = runTest {
val vm = ChatViewModel(FakeRepo(listOf("Hel", "lo!")))
vm.onInputChange("Hi")
vm.send()
advanceUntilIdle()
val last = vm.uiState.value.messages.last()
assertEquals("Hello!", last.text)
assertEquals(Status.DONE, last.status)
}
@Test
fun `network failure marks the reply as failed`() = runTest {
val vm = ChatViewModel(FakeRepo(emptyList(), fail = true))
vm.onInputChange("Hi")
vm.send()
advanceUntilIdle()
assertEquals(Status.FAILED, vm.uiState.value.messages.last().status)
}
}The MainDispatcherRule is shown in Kotlin Coroutines and Flow Explained.
Production checklist
Cost control
- History limited to recent turns
maxOutputTokensset- Input length capped
- Per-user daily limits (needs a backend or App Check + server rules)
Safety & trust
- System instruction keeps scope narrow
- Replies labelled AI-generated
- No actions taken from model output without user confirmation
- A way for users to report bad answers
Privacy
- Tell users messages go to a cloud AI service
- Data safety form updated
- Don't log full conversations to analytics
- Offer "Clear conversation"
UX polish
- Stop button while streaming
- Retry on failure
- Text selectable for copying
- Persist conversations in Room if users expect them later
Troubleshooting
- List jumps or flickers while streaming. Make sure items have stable keys (
key = { it.id }) and that you update the message in place rather than creating a new id. - The assistant "forgets" earlier context. That's expected beyond
maxHistoryMessages. Raise it carefully, or summarise older turns into a single context message. - Replies stop mid-sentence.
maxOutputTokensis too low, or safety filtering ended the response. Inspect the finish reason in debug builds. - Requests fail only in release builds. Usually App Check. Register your release SHA-256 fingerprint and check Play Integrity setup.
- Keyboard covers the input. Enable edge-to-edge and use
imePadding()on the input bar.
Frequently asked questions
Can this chatbot work offline?
Not with a cloud model. On-device Gemini Nano is currently suited to single-turn tasks rather than full multi-turn chat. See Cloud AI vs On-Device AI for hybrid options.
How do I make it answer from my own content?
Retrieve relevant passages from your content (usually on a backend with embeddings) and include them in the prompt. This is called retrieval-augmented generation (RAG).
Should I render Markdown in replies?
Models often reply with Markdown. Rendering it improves readability, but parsing on every streamed chunk can be expensive. Throttle updates or render only once the message is complete.
Can I use OpenAI or another provider instead?
Yes. Implement ChatRepository against your own backend that calls the other provider. The ViewModel and UI don't change, which is the point of the interface.
Conclusion
You've built a chatbot with the pieces that matter in production: immutable messages with stable ids, a repository that controls history and cost, a ViewModel that streams safely with stop and retry, and a Compose UI that stays smooth while text arrives. Because the AI provider sits behind ChatRepository, you can swap models, add a backend or introduce on-device features without rewriting the screen.
Next step: persist conversations with Room and add a "New chat" action. To speed up building the UI itself, see How to Use Gemini in Android Studio.
Official references
π± Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it βΈ
NotiVaultNotification history: recover dismissed notificationsGet it βΈ
Ready to build real Android applications?
Every AI feature still runs inside an Android app that has to be fast, reliable and well structured. Strengthen the Kotlin, Jetpack Compose and architecture skills that AI integrations depend on, then apply these AI concepts with confidence.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.