From Zero to AI: Building custom chat interfaces with Nutrient on Android

Table of contents

    From Zero to AI: Building custom chat interfaces with Nutrient on Android

    Want to add intelligent document interaction to your Android app? This step-by-step guide shows you how to integrate Nutrient AI Assistant with a completely custom user interface (UI) that matches your app’s design.

    Add AI-driven document interaction to your Android app

    Use the Nutrient SDK to embed responsive, assistant-ready PDF views into your Jetpack Compose UI.

    Instead of using the default AI Assistant interface, you’ll learn to build your own chat UI using Jetpack Compose, handle streaming responses, and create a seamless user experience. This post will walk you through a practical implementation using a real Android app as reference.

    What you’ll build

    • A custom chat interface with Material Design 3
    • Real-time streaming AI responses
    • Document-aware Q&A functionality
    • Clean architecture using the Repository pattern and MVI (Model-View-Intent) state management

    Prerequisites

    • Basic Android development experience
    • Familiarity with Jetpack Compose
    • Understanding of Kotlin coroutines

    Step 1 — Set up the AI Assistant backend

    First, you’ll need the Nutrient AI Assistant service running locally to handle document processing and AI responses.

    Quick setup with Docker

    Terminal window
    git clone https://github.com/PSPDFKit/ai-assistant-demo
    cd ai-assistant-demo
    docker-compose up

    This starts the service on http://localhost:3000. For more details, refer to the Nutrient AI Assistant documentation.

    Keep this terminal open — you’ll need the service running throughout development.

    Step 2 — Set up your Android project

    Add Nutrient SDK dependencies

    In your settings.gradle.kts, add the Nutrient Maven repository:

    dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
    google()
    mavenCentral()
    maven { url = uri("https://my.nutrient.io/maven") }
    }
    }

    In your app’s build.gradle.kts, add the Nutrient dependency:

    dependencies {
    implementation("io.nutrient:nutrient:LATEST_VERSION")
    }

    For more information on adding the Nutrient SDK to your app and latest version, refer to the Nutrient documentation.

    Reference implementation

    To see these concepts in action, check out StoryVoyage(opens in a new tab), which is a real-world book reading app that showcases AI Assistant integration. The app demonstrates how readers can ask questions about books they’re reading, getting instant answers about plot details, character analysis, and themes.

    Terminal window
    git clone https://github.com/akshay2211/StoryVoyage.git
    cd StoryVoyage

    StoryVoyage(opens in a new tab) is a fitting example to reference, as it addresses a real user problem: making reading more interactive and educational.

    Step 3 — Build the custom chat interface

    Now comes the fun part — building your custom chat interface! This is where your app’s personality shines. Instead of using the default AI Assistant UI, you’ll create a completely custom experience that matches your brand.

    Step 3.1 — Define your data models

    Start by creating the data structures that will power your chat experience:

    enum class MessageStatus {
    SENDING, SENT, FAILED
    }
    // AI Assistant events for reactive programming.
    sealed class AiAssistantEvents {
    object Chat : AiAssistantEvents()
    object Success : AiAssistantEvents()
    object Loading : AiAssistantEvents()
    data class Error(val message: String) : AiAssistantEvents()
    }

    Step 3.2 — Create state management with the MVI pattern

    Implement clean state management using the MVI pattern:

    // UI State — represents what the user sees.
    data class AiAssistantState(
    val isLoading: Boolean = false,
    val messages: List<ChatMessage> = emptyList(),
    val inputText: String = "",
    val isRecording: Boolean = false,
    val currentDocumentId: String? = null,
    val error: String? = null
    )
    // User Intents — represents what the user wants to do.
    sealed interface AiAssistantIntent {
    data class SendMessage(val message: String) : AiAssistantIntent
    data class UpdateInputText(val text: String) : AiAssistantIntent
    }

    Step 3.3 — Build the main chat screen

    Create the main composable that brings everything together:

    @Composable
    fun AiAssistantScreen(
    viewModel: AiAssistantViewModel = koinViewModel(),
    modifier: Modifier = Modifier
    ) {
    val state by viewModel.state.collectAsState()
    Column(
    modifier = modifier
    .fillMaxSize()
    .background(MaterialTheme.colorScheme.background)
    ) {
    // Chat header with document info.
    ChatHeader(
    documentName = state.currentDocumentId ?: "Document",
    isOnline = !state.error.isNullOrEmpty().not()
    )
    // Messages list.
    MessagesList(
    messages = state.messages,
    isLoading = state.isLoading,
    modifier = Modifier.weight(1f)
    )
    // Input area.
    ChatInput(
    inputText = state.inputText,
    onTextChanged = { viewModel.processIntent(AiAssistantIntent.UpdateInputText(it)) },
    onSendMessage = { viewModel.processIntent(AiAssistantIntent.SendMessage(it)) },
    )
    }
    }

    Step 3.4 — Create the messages list component

    Build a smooth, performant messages list with proper animations:

    @Composable
    fun MessagesList(
    messages: List<ChatMessage>,
    isLoading: Boolean,
    modifier: Modifier = Modifier
    ) {
    val listState = rememberLazyListState()
    // Auto-scroll to the bottom when new messages arrive.
    LaunchedEffect(messages.size) {
    if (messages.isNotEmpty()) {
    listState.animateScrollToItem(0) // `reverseLayout` = `true`, so 0 is the bottom.
    }
    }
    LazyColumn(
    modifier = modifier.fillMaxSize(),
    state = listState,
    reverseLayout = true, // New messages appear at the bottom.
    contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
    // Loading indicator at the top.
    if (isLoading) {
    item {
    TypingIndicator()
    }
    }
    // Messages in reverse order (newest first in data, but appears at bottom).
    items(
    items = messages.reversed(),
    key = { it.id }
    ) { message ->
    MessageItem(
    message = message,
    modifier = Modifier.animateItemPlacement()
    )
    }
    }
    }

    Step 3.5 — Design smart chat blocks

    Create intelligent message bubbles that adapt to content type:

    @Composable
    fun MessageItem(
    message: ChatMessage,
    modifier: Modifier = Modifier
    ) {
    val isFromUser = message.isFromUser
    Row(
    modifier = modifier
    .fillMaxWidth()
    .padding(horizontal = 8.dp, vertical = 4.dp),
    horizontalArrangement = if (isFromUser) Arrangement.End else Arrangement.Start
    ) {
    if (!isFromUser) {
    // AI avatar.
    AsyncImage(
    model = R.drawable.ai_avatar,
    contentDescription = "AI Assistant",
    modifier = Modifier
    .size(32.dp)
    .clip(CircleShape)
    .align(Alignment.Bottom)
    )
    Spacer(modifier = Modifier.width(8.dp))
    }
    // Message bubble.
    Surface(
    shape = RoundedCornerShape(
    topStart = 16.dp,
    topEnd = 16.dp,
    bottomStart = if (isFromUser) 16.dp else 4.dp,
    bottomEnd = if (isFromUser) 4.dp else 16.dp
    ),
    color = if (isFromUser)
    MaterialTheme.colorScheme.primary
    else
    MaterialTheme.colorScheme.surfaceVariant,
    modifier = Modifier.widthIn(max = 280.dp)
    ) {
    Column(
    modifier = Modifier.padding(12.dp)
    ) {
    // Message content with Markdown support.
    if (message.isMarkdown && !isFromUser) {
    MarkdownText(
    markdown = message.content,
    color = MaterialTheme.colorScheme.onSurfaceVariant
    )
    } else {
    Text(
    text = message.content,
    color = if (isFromUser)
    MaterialTheme.colorScheme.onPrimary
    else
    MaterialTheme.colorScheme.onSurfaceVariant,
    style = MaterialTheme.typography.bodyMedium
    )
    }
    // Message status and timestamp.
    Row(
    modifier = Modifier
    .fillMaxWidth()
    .padding(top = 4.dp),
    horizontalArrangement = Arrangement.End,
    verticalAlignment = Alignment.CenterVertically
    ) {
    Text(
    text = formatTimestamp(message.timestamp),
    style = MaterialTheme.typography.labelSmall,
    color = if (isFromUser)
    MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
    else
    MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
    )
    if (isFromUser) {
    Spacer(modifier = Modifier.width(4.dp))
    MessageStatusIcon(status = message.status)
    }
    }
    }
    }
    if (isFromUser) {
    Spacer(modifier = Modifier.width(8.dp))
    // User avatar.
    AsyncImage(
    model = R.drawable.user_avatar,
    contentDescription = "You",
    modifier = Modifier
    .size(32.dp)
    .clip(CircleShape)
    .align(Alignment.Bottom)
    )
    }
    }
    }

    Step 3.6 — Add polish with loading and error states

    Complete the UI with loading indicators and error handling:

    @Composable
    fun TypingIndicator() {
    Row(
    modifier = Modifier.padding(16.dp),
    verticalAlignment = Alignment.CenterVertically
    ) {
    // AI avatar.
    AsyncImage(
    model = R.drawable.ai_avatar,
    contentDescription = "AI Assistant",
    modifier = Modifier
    .size(32.dp)
    .clip(CircleShape)
    )
    Spacer(modifier = Modifier.width(8.dp))
    // Animated typing dots.
    Surface(
    shape = RoundedCornerShape(16.dp),
    color = MaterialTheme.colorScheme.surfaceVariant
    ) {
    Row(
    modifier = Modifier.padding(16.dp, 12.dp),
    horizontalArrangement = Arrangement.spacedBy(4.dp)
    ) {
    repeat(3) { index ->
    val infiniteTransition = rememberInfiniteTransition()
    val alpha by infiniteTransition.animateFloat(
    initialValue = 0.3f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
    animation = tween(600),
    repeatMode = RepeatMode.Reverse
    )
    )
    Box(
    modifier = Modifier
    .size(8.dp)
    .background(
    MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
    CircleShape
    )
    )
    }
    }
    }
    }
    }

    Status icon for messages:

    @Composable
    fun MessageStatusIcon(status: MessageStatus) {
    val (icon, color) = when (status) {
    MessageStatus.SENDING -> Icons.Default.Schedule to MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
    MessageStatus.SENT -> Icons.Default.Done to MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
    MessageStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
    }
    Icon(
    imageVector = icon,
    contentDescription = status.name,
    modifier = Modifier.size(16.dp),
    tint = color
    )
    }

    Pro tips for step 3

    • Use animateItemPlacement() for smooth message animations.
    • Implement proper state hoisting to keep components testable.
    • Consider implementing message retry functionality.
    • Use LaunchedEffect to handle side effects properly.

    Step 4 — Connect to the AI Assistant service (ViewModel and Repository)

    Now you’ll build the business logic layer that connects your UI to the Nutrient AI Assistant service. This step covers the ViewModel and Repository implementation.

    Step 4.1 — Create the Repository interface

    First, define the contract for AI Assistant operations:

    interface AiAssistantRepository {
    // Stream of responses from AI assistant.
    val responseStream: Flow<CompletionResponse?>
    // Initialize with document provider and identifiers.
    suspend fun initialize(
    pdfDocument: PdfDocument,
    dataProvider: DataProvider,
    documentIdentifiers: DocumentIdentifiers,
    isRefresh: Boolean = false
    ): Boolean
    // Send a message to AI assistant.
    suspend fun sendMessage(message: String, documentId: String)
    // Terminate AI assistant.
    suspend fun terminate()
    }

    Step 4.2 — Implement the data source

    Create the data source that handles direct communication with the AI Assistant service:

    class AiAssistantDataSourceImpl(
    private val context: Context
    ) : AiAssistantDataSource {
    var aiAssistant: AiAssistant? = null
    override val responseState: Flow<CompletionResponse?>
    get() = aiAssistant?.responseState ?: emptyFlow()
    override suspend fun initialize(
    pdfDocument: PdfDocument,
    dataProvider: DataProvider,
    documentIdentifiers: DocumentIdentifiers,
    isRefresh: Boolean
    ): Boolean {
    return try {
    val session = pdfDocument.title?.replace(titleRegex, "") ?: "default-session"
    val aiAssistantConfiguration = AiAssistantConfiguration(
    "http://localhost:4000",
    JwtGenerator.generateJwtToken(
    context,
    claims = mapOf(
    "document_ids" to listOf(documentIdentifiers.permanentId),
    "session_ids" to listOf(session),
    "request_limit" to mapOf("requests" to 30, "time_period_s" to 1000 * 60)
    )
    ),
    session
    )
    aiAssistant = standaloneAiAssistant(context, aiAssistantConfiguration)
    pdfDocument.setAiAssistant(aiAssistant!!)
    aiAssistant?.initialize(dataProvider, documentIdentifiers, isRefresh)
    true
    } catch (e: Exception) {
    Log.e("AiAssistant", "Failed to initialize AI Assistant with document provider", e)
    false
    }
    }
    override suspend fun emitMessage(message: String, documentId: String) {
    val id = aiAssistant?.identifiers?.permanentId ?: ""
    aiAssistant?.emitMessage(message, id)
    }
    override suspend fun terminate() {
    aiAssistant?.terminate()
    }
    }

    Step 4.3 — Build the ViewModel

    Create the ViewModel that manages UI state and coordinates use cases:

    class AiAssistantViewModel(
    private val repository: AiAssistantRepository
    ) : ViewModel() {
    private val _state = MutableStateFlow(AiAssistantState())
    val state: StateFlow<AiAssistantState> = _state.asStateFlow()
    init {
    observeAiResponses()
    }
    fun processIntent(intent: AiAssistantIntent) {
    when (intent) {
    is AiAssistantIntent.SendMessage -> {
    sendMessage(intent.message)
    }
    is AiAssistantIntent.UpdateInputText -> {
    _state.update { it.copy(inputText = intent.text) }
    }
    }
    }
    private fun observeAiResponses() {
    viewModelScope.launch {
    repository.responseStream.collect { response ->
    response?.let { handleAiResponse(it) }
    }
    }
    }
    private fun addMessage(message: CompletionResponse) {
    _state.update { currentState ->
    currentState.copy(
    messages = currentState.messages + message
    )
    }
    }
    fun sendMessage(message: String) {
    if (message.isBlank()) return
    viewModelScope.launch {
    // Clear input.
    _state.update { it.copy(inputText = "", isLoading = true) }
    try {
    // Get document ID from current conversation, or use a default.
    val documentId = _state.value.currentDocumentId ?: "default"
    // Send message to AI Assistant.
    repository.sendMessage(message, documentId)
    // The response will be handled by the flow collector in init.
    } catch (e: Exception) {
    _state.update { it.copy(isLoading = false, error = "Failed to send message: ${e.message}") }
    }
    }
    }
    private fun handleAiResponse(response: CompletionResponse) {
    viewModelScope.launch {
    when (response.state) {
    is AiAssistantEvents.Chat -> {
    _state.update { currentState ->
    // Check if this is a new message or if we need to append to an existing one.
    if (response.sender.isEmpty() && (currentState.messages.isEmpty() || !response.end)) {
    // This is a new message — add it to the list.
    currentState.copy(
    isLoading = false,
    messages = currentState.messages + response
    )
    } else if (response.end && response.sender == "AI") {
    // This is the final part of a message — mark it as complete.
    val updatedMessages = currentState.messages.toMutableList()
    if (updatedMessages.isNotEmpty()) {
    val lastIndex = updatedMessages.lastIndex
    updatedMessages[lastIndex] = updatedMessages[lastIndex].copy(end = true)
    }
    currentState.copy(
    isLoading = false,
    messages = updatedMessages
    )
    } else {
    // This is a continuation of the current message — append the content.
    val updatedMessages = currentState.messages.toMutableList()
    if (updatedMessages.isNotEmpty()) {
    val lastIndex = updatedMessages.lastIndex
    updatedMessages[lastIndex] = updatedMessages[lastIndex].copy(
    content = updatedMessages[lastIndex].content + response.content
    )
    }
    currentState.copy(
    isLoading = false,
    messages = updatedMessages
    )
    }
    }
    _state.update { it.copy(isLoading = false) }
    }
    is AiAssistantEvents.Success -> {
    if (response.content.isNullOrEmpty()) return@launch
    addMessage(response)
    _state.update { it.copy(isLoading = false) }
    }
    is AiAssistantEvents.Loading -> {
    addMessage(response)
    _state.update { it.copy(isLoading = true) }
    }
    }
    }
    }
    }

    Step 5 — Wire everything together with dependency injection

    Now you’ll connect all the pieces using Koin for dependency injection and see how everything works together.

    Step 5.1 — Define the Koin module

    Set up dependency injection to manage all your components:

    val aiAssistantModule = module {
    // Data sources.
    single<AiAssistantDataSource> {
    AiAssistantDataSource(get(), get())
    }
    // Repositories.
    single<AiAssistantRepository> {
    AiAssistantRepositoryImpl(get())
    }
    // `ViewModel`s.
    viewModel { AiAssistantViewModel(get()) }
    ...
    }

    Step 5.2 — Data flow summary

    Here’s how data flows through your complete architecture:

    1. User sends message → UI calls viewModel.processIntent(SendMessage)
    2. ViewModel processes intent → Calls repository.sendMessage()
    3. Data source makes API call → Sends HTTP request to the AI Assistant service
    4. AI service responds → Returns CompletionResponse
    5. Data source emits response → Updates responseState StateFlow
    6. ViewModel observes → Converts response to ChatMessage
    7. UI reactsStateFlow triggers recomposition with new message

    This creates a complete reactive flow from UI → business logic → network → AI service and back!

    Step 6 — Test your AI-powered app

    Run the complete integration

    1. Verify the AI Assistant service is running at http://localhost:3000.
    2. Generate a JWT private key and place it in app/src/main/assets/keys/jwt.pem.
    3. Sync your project and resolve any dependencies.
    4. Run the app on a device or emulator.

    Test the user experience

    1. Load a PDF document in your app.
    2. Open the AI Assistant chat interface.
    3. Ask questions and give commands like “What is this document about?” or “Summarize the key points.”
    4. Observe streaming responses appearing in real time.
    5. Test error scenarios by disconnecting the network.

    Conclusion

    You’ve now learned how to integrate Nutrient AI Assistant with a completely custom UI in your Android app. This step-by-step approach gives you full control over the user experience while leveraging powerful AI capabilities.

    Next steps

    1. Explore the complete implementation in the StoryVoyage(opens in a new tab) repository.
    2. Adapt the patterns to your specific use case and design requirements.
    3. Deploy the AI Assistant service to your production infrastructure.
    4. Gather user feedback and iterate on the AI experience.
    Build smarter document experiences with Compose + AI

    Nutrient’s SDK makes it easy to embed rich, assistant-driven PDF workflows in your Android app — combining Jetpack Compose flexibility with intelligent document handling.

    The combination of Nutrient’s powerful document processing and your custom UI creates endless possibilities for intelligent document interaction. Whether you’re building educational apps, productivity tools, or entertainment platforms, these patterns will help you create engaging AI-powered experiences.

    Akshay Sharma

    Akshay Sharma

    Senior Android Engineer

    Akshay is an open source enthusiast who loves creating mobile apps and traveling to new places. He usually spends his free time with his family.

    Explore related topics

    FREE TRIAL Ready to get started?