// 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?
- Native Performance: Faster than WebViews
- Platform-Specific UI: SwiftUI and Jetpack Compose for modern mobile UX
- Offline Capabilities: Cache responses for better UX
- Push Notifications: Alert users to responses
- Shared Authentication: Single sign-on across platforms
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
- Test streaming: Send a message and verify real-time updates
- Test authentication: Ensure tokens are properly sent
- Test error handling: Disconnect network mid-stream
- Test UI responsiveness: Send multiple messages quickly
Production Considerations
- Rate Limiting: Implement per-user limits
- Offline Support: Cache recent conversations
- Push Notifications: Alert users to responses
- Analytics: Track usage patterns
- Cost Monitoring: Alert on high API usage
The complete chatbot implementation with web, iOS, and Android support provides a seamless cross-platform AI experience!