back to learning

// Mobile · Android

Mobile AI Chatbot Integration: Android Native Implementation


Editor’s note: this guide was originally written against the V1 chatbot API at api.mycuppa.io/chat/*, which is being retired. The patterns (SSE streaming, JWT auth, Jetpack Compose integration) all still hold — substitute your own LLM API endpoint for the URLs shown below.

Bringing your AI chatbot to mobile apps provides users with native, fast experiences. This guide shows you how to integrate the chatbot API into native Android apps using Jetpack Compose, with streaming responses and seamless authentication.

Why Native Mobile Integration?

Architecture Overview

┌─────────────────────────────────────┐
│     Chatbot API Service             │
│   (api.mycuppa.io/chat/*)           │
│                                     │
│  • Streaming SSE responses          │
│  • JWT authentication               │
│  • Platform-agnostic                │
└──────────────┬──────────────────────┘

        ┌──────┴──────────┐
        │                 │
┌───────▼──────┐   ┌─────▼────────┐
│  iOS App     │   │ Android App  │
│  (SwiftUI)   │   │ (Compose)    │
│              │   │              │
│ • Native UI  │   │ • Native UI  │
│ • Streaming  │   │ • Streaming  │
│ • Keychain   │   │ • Encrypted  │
└──────────────┘   └──────────────┘

iOS Implementation (SwiftUI)

Step 1: Create ChatbotService

// MyCuppa.iOS/App/Services/ChatbotService.swift
import Foundation
import Combine

@MainActor
class ChatbotService: ObservableObject {
    @Published var messages: [ChatMessage] = []
    @Published var isLoading = false
    @Published var error: ChatError?

    private let baseURL: String
    private let authManager: AuthenticationManager
    private var cancellables = Set<AnyCancellable>()

    init(
        baseURL: String = "https://api.mycuppa.io",
        authManager: AuthenticationManager = .shared
    ) {
        self.baseURL = baseURL
        self.authManager = authManager
    }

    func sendMessage(_ content: String, conversationId: String? = nil) async throws {
        guard !content.isEmpty else { return }

        isLoading = true
        defer { isLoading = false }

        // Add user message immediately
        let userMessage = ChatMessage(
            id: UUID().uuidString,
            role: .user,
            content: content,
            timestamp: Date()
        )
        messages.append(userMessage)

        // Prepare request
        guard let url = URL(string: "\(baseURL)/api/chat/message") else {
            throw ChatError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        // Add auth token
        if let token = try? await authManager.getAccessToken() {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        // Request body
        let body: [String: Any] = [
            "message": content,
            "conversationId": conversationId ?? UUID().uuidString,
            "stream": true
        ]
        request.httpBody = try JSONSerialization.data(withJSONObject: body)

        // Create assistant message placeholder
        let assistantMessageId = UUID().uuidString
        let assistantMessage = ChatMessage(
            id: assistantMessageId,
            role: .assistant,
            content: "",
            timestamp: Date()
        )
        messages.append(assistantMessage)

        // Stream response
        do {
            let (bytes, response) = try await URLSession.shared.bytes(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw ChatError.invalidResponse
            }

            guard httpResponse.statusCode == 200 else {
                throw ChatError.serverError(httpResponse.statusCode)
            }

            // Process SSE stream
            var currentContent = ""
            for try await line in bytes.lines {
                if line.hasPrefix("data: ") {
                    let data = String(line.dropFirst(6))

                    if data == "[DONE]" {
                        break
                    }

                    if let token = parseSSEToken(data) {
                        currentContent += token

                        // Update message
                        if let index = messages.firstIndex(where: { $0.id == assistantMessageId }) {
                            messages[index].content = currentContent
                        }
                    }
                }
            }
        } catch {
            // Remove placeholder message on error
            messages.removeAll { $0.id == assistantMessageId }
            throw ChatError.streamError(error)
        }
    }

    private func parseSSEToken(_ data: String) -> String? {
        guard let jsonData = data.data(using: .utf8) else { return nil }

        do {
            let decoded = try JSONDecoder().decode(SSEToken.self, from: jsonData)
            return decoded.token
        } catch {
            return nil
        }
    }

    func clearMessages() {
        messages.removeAll()
    }
}

// MARK: - Models

struct ChatMessage: Identifiable, Codable {
    let id: String
    let role: MessageRole
    var content: String
    let timestamp: Date

    enum MessageRole: String, Codable {
        case user
        case assistant
    }
}

struct SSEToken: Codable {
    let token: String
}

enum ChatError: LocalizedError {
    case invalidURL
    case invalidResponse
    case serverError(Int)
    case streamError(Error)
    case authenticationFailed

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "Invalid API URL"
        case .invalidResponse:
            return "Invalid response from server"
        case .serverError(let code):
            return "Server error: \(code)"
        case .streamError(let error):
            return "Streaming error: \(error.localizedDescription)"
        case .authenticationFailed:
            return "Authentication failed"
        }
    }
}

Step 2: Create SwiftUI Chat View

// MyCuppa.iOS/Features/Chat/Views/ChatView.swift
import SwiftUI

struct ChatView: View {
    @StateObject private var chatbot = ChatbotService()
    @State private var inputText = ""
    @State private var conversationId: String?
    @FocusState private var isInputFocused: Bool

    var body: some View {
        VStack(spacing: 0) {
            // Messages list
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack(spacing: 16) {
                        ForEach(chatbot.messages) { message in
                            ChatBubbleView(message: message)
                                .id(message.id)
                        }

                        if chatbot.isLoading {
                            TypingIndicatorView()
                        }
                    }
                    .padding()
                }
                .onChange(of: chatbot.messages.count) { _ in
                    if let lastMessage = chatbot.messages.last {
                        withAnimation {
                            proxy.scrollTo(lastMessage.id, anchor: .bottom)
                        }
                    }
                }
            }

            Divider()

            // Input area
            HStack(spacing: 12) {
                TextField("Ask about Cuppa...", text: $inputText, axis: .vertical)
                    .textFieldStyle(.roundedBorder)
                    .focused($isInputFocused)
                    .lineLimit(1...5)

                Button {
                    sendMessage()
                } label: {
                    Image(systemName: "arrow.up.circle.fill")
                        .font(.title2)
                        .foregroundColor(inputText.isEmpty ? .gray : .blue)
                }
                .disabled(inputText.isEmpty || chatbot.isLoading)
            }
            .padding()
        }
        .navigationTitle("AI Assistant")
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                Button {
                    chatbot.clearMessages()
                    conversationId = nil
                } label: {
                    Image(systemName: "trash")
                }
            }
        }
        .alert("Error", isPresented: .constant(chatbot.error != nil)) {
            Button("OK") {
                chatbot.error = nil
            }
        } message: {
            Text(chatbot.error?.errorDescription ?? "Unknown error")
        }
    }

    private func sendMessage() {
        let message = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !message.isEmpty else { return }

        inputText = ""
        isInputFocused = false

        // Generate or reuse conversation ID
        let convId = conversationId ?? UUID().uuidString
        conversationId = convId

        Task {
            do {
                try await chatbot.sendMessage(message, conversationId: convId)
            } catch {
                chatbot.error = error as? ChatError ?? .streamError(error)
            }
        }
    }
}

// MARK: - Chat Bubble

struct ChatBubbleView: View {
    let message: ChatMessage

    var body: some View {
        HStack {
            if message.role == .assistant {
                Spacer(minLength: 40)
            }

            VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) {
                Text(message.content)
                    .padding(12)
                    .background(backgroundColor)
                    .foregroundColor(textColor)
                    .cornerRadius(16)

                Text(message.timestamp, style: .time)
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }

            if message.role == .user {
                Spacer(minLength: 40)
            }
        }
    }

    private var backgroundColor: Color {
        message.role == .user ? .blue : Color(.systemGray6)
    }

    private var textColor: Color {
        message.role == .user ? .white : .primary
    }
}

// MARK: - Typing Indicator

struct TypingIndicatorView: View {
    @State private var animationOffset: CGFloat = 0

    var body: some View {
        HStack(spacing: 4) {
            ForEach(0..<3) { index in
                Circle()
                    .fill(Color.gray)
                    .frame(width: 8, height: 8)
                    .offset(y: animationOffset)
                    .animation(
                        Animation
                            .easeInOut(duration: 0.6)
                            .repeatForever()
                            .delay(Double(index) * 0.2),
                        value: animationOffset
                    )
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding()
        .onAppear {
            animationOffset = -8
        }
    }
}

Step 3: Add to Navigation

// MyCuppa.iOS/App/Views/MainTabView.swift
struct MainTabView: View {
    var body: some View {
        TabView {
            // ... other tabs

            NavigationView {
                ChatView()
            }
            .tabItem {
                Label("Chat", systemImage: "message")
            }
        }
    }
}

Android Implementation (Jetpack Compose)

Step 1: Create ChatbotRepository

// MyCuppa.Android/data/repository/ChatbotRepository.kt
package com.mycuppa.data.repository

import com.mycuppa.data.remote.ChatbotApiService
import com.mycuppa.domain.model.ChatMessage
import com.mycuppa.domain.model.MessageRole
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject

class ChatbotRepository @Inject constructor(
    private val apiService: ChatbotApiService,
    private val authManager: AuthenticationManager
) {
    suspend fun sendMessage(
        message: String,
        conversationId: String
    ): Flow<String> = flow {
        try {
            val token = authManager.getAccessToken()

            apiService.streamMessage(
                authorization = "Bearer $token",
                request = ChatRequest(
                    message = message,
                    conversationId = conversationId,
                    stream = true
                )
            ).collect { sseEvent ->
                if (sseEvent.data != "[DONE]") {
                    try {
                        val tokenData = Json.decodeFromString<SSEToken>(sseEvent.data)
                        emit(tokenData.token)
                    } catch (e: Exception) {
                        // Skip invalid JSON
                    }
                }
            }
        } catch (e: Exception) {
            throw ChatException.StreamError(e)
        }
    }
}

// Data classes
@Serializable
data class ChatRequest(
    val message: String,
    val conversationId: String,
    val stream: Boolean = true
)

@Serializable
data class SSEToken(
    val token: String
)

sealed class ChatException : Exception() {
    data class StreamError(val cause: Throwable) : ChatException()
    object AuthenticationFailed : ChatException()
    data class ServerError(val code: Int) : ChatException()
}

Step 2: Create ViewModel

// MyCuppa.Android/ui/chat/ChatViewModel.kt
package com.mycuppa.ui.chat

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mycuppa.data.repository.ChatbotRepository
import com.mycuppa.domain.model.ChatMessage
import com.mycuppa.domain.model.MessageRole
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject

@HiltViewModel
class ChatViewModel @Inject constructor(
    private val repository: ChatbotRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ChatUiState())
    val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()

    private var conversationId: String? = null

    fun sendMessage(content: String) {
        if (content.isBlank()) return

        val convId = conversationId ?: UUID.randomUUID().toString().also {
            conversationId = it
        }

        // Add user message
        val userMessage = ChatMessage(
            id = UUID.randomUUID().toString(),
            role = MessageRole.USER,
            content = content,
            timestamp = System.currentTimeMillis()
        )

        _uiState.update { state ->
            state.copy(
                messages = state.messages + userMessage,
                isLoading = true,
                error = null
            )
        }

        // Create placeholder for assistant message
        val assistantMessageId = UUID.randomUUID().toString()
        val assistantMessage = ChatMessage(
            id = assistantMessageId,
            role = MessageRole.ASSISTANT,
            content = "",
            timestamp = System.currentTimeMillis()
        )

        _uiState.update { state ->
            state.copy(messages = state.messages + assistantMessage)
        }

        // Stream response
        viewModelScope.launch {
            var currentContent = ""

            repository.sendMessage(content, convId)
                .catch { error ->
                    // Remove placeholder on error
                    _uiState.update { state ->
                        state.copy(
                            messages = state.messages.filter { it.id != assistantMessageId },
                            isLoading = false,
                            error = error.message ?: "Unknown error"
                        )
                    }
                }
                .collect { token ->
                    currentContent += token

                    // Update assistant message
                    _uiState.update { state ->
                        state.copy(
                            messages = state.messages.map { msg ->
                                if (msg.id == assistantMessageId) {
                                    msg.copy(content = currentContent)
                                } else {
                                    msg
                                }
                            },
                            isLoading = false
                        )
                    }
                }
        }
    }

    fun clearMessages() {
        _uiState.value = ChatUiState()
        conversationId = null
    }
}

data class ChatUiState(
    val messages: List<ChatMessage> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

Step 3: Create Composable UI

// MyCuppa.Android/ui/chat/ChatScreen.kt
package com.mycuppa.ui.chat

import androidx.compose.foundation.layout.*
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.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.mycuppa.domain.model.ChatMessage
import com.mycuppa.domain.model.MessageRole
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
    viewModel: ChatViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    var inputText by remember { mutableStateOf("") }
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

    // Auto-scroll to bottom on new messages
    LaunchedEffect(uiState.messages.size) {
        if (uiState.messages.isNotEmpty()) {
            coroutineScope.launch {
                listState.animateScrollToItem(uiState.messages.size - 1)
            }
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("AI Assistant") },
                actions = {
                    IconButton(onClick = { viewModel.clearMessages() }) {
                        Icon(Icons.Default.Delete, "Clear chat")
                    }
                }
            )
        }
    ) { padding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
        ) {
            // Messages
            LazyColumn(
                state = listState,
                modifier = Modifier
                    .weight(1f)
                    .fillMaxWidth(),
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(16.dp)
            ) {
                items(uiState.messages) { message ->
                    ChatBubble(message = message)
                }

                if (uiState.isLoading) {
                    item {
                        TypingIndicator()
                    }
                }
            }

            // Error message
            uiState.error?.let { error ->
                Text(
                    text = error,
                    color = MaterialTheme.colorScheme.error,
                    modifier = Modifier.padding(horizontal = 16.dp)
                )
            }

            // Input
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                OutlinedTextField(
                    value = inputText,
                    onValueChange = { inputText = it },
                    modifier = Modifier.weight(1f),
                    placeholder = { Text("Ask about Cuppa...") },
                    maxLines = 5
                )

                IconButton(
                    onClick = {
                        viewModel.sendMessage(inputText)
                        inputText = ""
                    },
                    enabled = inputText.isNotBlank() && !uiState.isLoading
                ) {
                    Icon(Icons.Default.Send, "Send message")
                }
            }
        }
    }
}

@Composable
fun ChatBubble(message: ChatMessage) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = if (message.role == MessageRole.USER) {
            Arrangement.End
        } else {
            Arrangement.Start
        }
    ) {
        if (message.role == MessageRole.ASSISTANT) {
            Spacer(modifier = Modifier.width(40.dp))
        }

        Card(
            colors = CardDefaults.cardColors(
                containerColor = if (message.role == MessageRole.USER) {
                    MaterialTheme.colorScheme.primary
                } else {
                    MaterialTheme.colorScheme.surfaceVariant
                }
            ),
            shape = RoundedCornerShape(16.dp),
            modifier = Modifier.widthIn(max = 280.dp)
        ) {
            Column(
                modifier = Modifier.padding(12.dp)
            ) {
                Text(
                    text = message.content,
                    color = if (message.role == MessageRole.USER) {
                        MaterialTheme.colorScheme.onPrimary
                    } else {
                        MaterialTheme.colorScheme.onSurfaceVariant
                    }
                )

                Text(
                    text = formatTimestamp(message.timestamp),
                    style = MaterialTheme.typography.labelSmall,
                    color = if (message.role == MessageRole.USER) {
                        MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
                    } else {
                        MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
                    },
                    modifier = Modifier.padding(top = 4.dp)
                )
            }
        }

        if (message.role == MessageRole.USER) {
            Spacer(modifier = Modifier.width(40.dp))
        }
    }
}

@Composable
fun TypingIndicator() {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Start
    ) {
        Card(
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.surfaceVariant
            ),
            shape = RoundedCornerShape(16.dp)
        ) {
            Row(
                modifier = Modifier.padding(16.dp),
                horizontalArrangement = Arrangement.spacedBy(4.dp)
            ) {
                repeat(3) { index ->
                    Box(
                        modifier = Modifier
                            .size(8.dp)
                            .background(
                                MaterialTheme.colorScheme.onSurfaceVariant,
                                CircleShape
                            )
                    )
                }
            }
        }
    }
}

private fun formatTimestamp(timestamp: Long): String {
    val date = Date(timestamp)
    val format = SimpleDateFormat("HH:mm", Locale.getDefault())
    return format.format(date)
}

Authentication Integration

Both platforms use shared JWT authentication:

iOS Token Storage

// Store in Keychain
try keychain.set(token, key: "access_token")

Android Token Storage

// Store in EncryptedSharedPreferences
encryptedPrefs.edit().putString("access_token", token).apply()

Testing the Integration

  1. Test streaming: Send a message and verify real-time updates
  2. Test authentication: Ensure tokens are properly sent
  3. Test error handling: Disconnect network mid-stream
  4. Test UI responsiveness: Send multiple messages quickly

Production Considerations

The complete chatbot implementation with web, iOS, and Android support provides a seamless cross-platform AI experience!