πŸ€– Android AI and Generative AI

Build an AI Chatbot Using Kotlin and Jetpack Compose

A complete, working chatbot project: a clean data model, a repository around Gemini's chat API, a ViewModel that streams tokens into message bubbles, a polished Compose UI with stop and retry, and the production details (history limits, safety, cost) that tutorials usually skip.

✍️ Yunok Tech πŸ“… Updated ⏱ 4 min read 🎯 Intermediate
On this page
  1. What we'll build
  2. Project structure
  3. Step 1: The message model
  4. Step 2: The repository
  5. Step 3: The ViewModel
  6. Step 4: The Compose UI
  7. Step 5: Test the ViewModel with a fake repository
  8. Production checklist
  9. Troubleshooting
  10. Frequently asked questions
  11. Conclusion

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.
Chat message lifecycle from send to streamed reply The user sends a message. The ViewModel appends a user bubble and an empty assistant bubble marked streaming, then calls the repository. The repository sends recent history plus the new message to Gemini with sendMessageStream. Each streamed chunk is appended to the assistant bubble. When the stream completes, the bubble is marked done; on error it is marked failed with retry. User tapsSend ViewModel+ user bubble+ empty AI bubble ChatRepositorytrimmed historysendMessageStream() Gemini (cloud)streams chunks each chunk β†’ append to AI bubble β†’ recompose complete β†’ status DONE error β†’ FAILED + Retry
The assistant bubble exists before the first token arrives, so the UI can show a typing indicator and then fill the same bubble in place.

Project structure

Files we'll createText
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 bar

Step 1: The message model

ChatMessage.ktKotlin
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.

ChatRepository.ktKotlin
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 }
    }
}
Why filter failed turns?

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

ChatViewModel.ktKotlin
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

ChatScreen.ktKotlin
@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") }
            }
        }
    }
}
Wiring it up

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

ChatViewModelTest.ktKotlin
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
  • maxOutputTokens set
  • 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. maxOutputTokens is 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.

See all our apps β†’

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.

Share: LinkedIn X / Twitter WhatsApp