Implement the app logic in MainActivity.kt to use the AI Chat library for LLM interaction.
First, replace the default imports with the following:
import android.os.Bundle
import android.net.Uri
import android.provider.OpenableColumns
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.arm.aichat.AiChat
import com.arm.aichat.InferenceEngine
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.util.UUID
import kotlin.math.max
Within the MainActivity class, define all the class variables needed for UI components and AI Chat functionality:
class MainActivity : AppCompatActivity() {
private lateinit var rootView: androidx.constraintlayout.widget.ConstraintLayout // Root layout used to apply resizing.
private lateinit var statusText: TextView // Status label that reports engine and model state.
private lateinit var messagesView: RecyclerView // Message list showing the chat history.
private lateinit var userInput: EditText // Text box for the user's prompt.
private lateinit var sendButton: MaterialButton // Button that imports a model first, then sends prompts.
private lateinit var engine: InferenceEngine // Single ai-chat engine instance for the activity.
private val messages = mutableListOf<Message>() // Backing list for chat messages.
private val adapter = MessageAdapter(messages) // RecyclerView adapter bound to the message list.
private val lastAssistantMessage = StringBuilder() // Buffer used while assistant tokens stream in.
private var modelReady = false // Tracks whether a model has finished loading.
private var sending = false // Prevents overlapping prompt submissions.
private var loadedModelName = "chat.gguf" // Remembers the original imported filename for status text.
Add the model loading logic that copies the selected GGUF model file to local storage and loads it into the inference engine: // Fixed local destination for the copied model file. private val importedModelFile: File get() = File(ensureModelsDirectory(), “chat.gguf”)
// One-time file picker used to import a GGUF from device storage.
private val importModel = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { handleSelectedModel(it) }
}
private fun handleSelectedModel(uri: Uri) {
sendButton.isEnabled = false
statusText.text = "Copying selected model..."
lifecycleScope.launch {
try {
loadedModelName = resolveDisplayName(uri)
val modelFile = copySelectedModel(uri)
statusText.text = "Loading model: $loadedModelName"
withContext(Dispatchers.IO) {
engine.loadModel(modelFile.absolutePath)
}
modelReady = true
userInput.isEnabled = true
userInput.hint = "Ask something"
sendButton.isEnabled = true
sendButton.text = "Send"
statusText.text = "Model ready: $loadedModelName"
} catch (t: Throwable) {
sendButton.isEnabled = true
statusText.text = "Failed to import model $loadedModelName: ${t.message ?: t::class.java.simpleName}"
Toast.makeText(this@MainActivity, "Model import failed", Toast.LENGTH_SHORT).show()
}
}
}
private fun resolveDisplayName(uri: Uri): String {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) {
return cursor.getString(nameIndex)
}
}
return uri.lastPathSegment ?: "chat.gguf"
}
private suspend fun copySelectedModel(uri: Uri): File =
withContext(Dispatchers.IO) {
val targetFile = importedModelFile
contentResolver.openInputStream(uri)?.use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
}
} ?: throw IOException("Unable to open selected model file")
targetFile
}
// Ensures the private model directory exists before copying the file.
private fun ensureModelsDirectory(): File =
File(filesDir, "models").also { directory ->
if (directory.exists() && !directory.isDirectory) {
directory.delete()
}
if (!directory.exists()) {
directory.mkdirs()
}
}
## Initialize the app
Add the functions for creating and initializing the app UI and inference engine:
```kotlin
// Wires the UI and prepares the engine for model import.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
rootView = findViewById(R.id.root)
statusText = findViewById(R.id.status_text)
messagesView = findViewById(R.id.messages)
userInput = findViewById(R.id.user_input)
sendButton = findViewById(R.id.send_button)
applyWindowInsets()
messagesView.layoutManager = LinearLayoutManager(this)
messagesView.adapter = adapter
sendButton.setOnClickListener {
if (modelReady) {
sendCurrentPrompt()
} else {
importModel.launch(arrayOf("*/" + "*"))
}
}
initializeEngine()
}
// Creates the inference engine and enables the initial import flow.
private fun initializeEngine() {
lifecycleScope.launch {
statusText.text = "Creating inference engine..."
try {
engine = withContext(Dispatchers.Default) {
AiChat.getInferenceEngine(applicationContext)
}
sendButton.isEnabled = true
userInput.hint = "Import a model first"
statusText.text = "Choose a GGUF model from Downloads"
} catch (t: Throwable) {
statusText.text = "Failed to create engine: ${t.message ?: t::class.java.simpleName}"
}
}
}
Implement the chat messaging logic that sends user prompts to the LLM and streams the response tokens back to the UI:
// Sends the current prompt and streams the assistant response into the last row.
private fun sendCurrentPrompt() {
val prompt = userInput.text.toString().trim()
if (!modelReady || sending || prompt.isEmpty()) {
return
}
userInput.text?.clear()
userInput.isEnabled = false
sendButton.isEnabled = false
sending = true
messages.add(Message(UUID.randomUUID().toString(), prompt, true))
adapter.notifyItemInserted(messages.lastIndex)
messagesView.scrollToPosition(messages.lastIndex)
lastAssistantMessage.clear()
messages.add(Message(UUID.randomUUID().toString(), "", false))
adapter.notifyItemInserted(messages.lastIndex)
messagesView.scrollToPosition(messages.lastIndex)
lifecycleScope.launch(Dispatchers.Default) {
try {
engine.sendUserPrompt(prompt)
.onCompletion {
withContext(Dispatchers.Main) {
sending = false
userInput.isEnabled = true
sendButton.isEnabled = true
}
}
.collect { token ->
val assistantIndex = messages.lastIndex
val updatedMessage = messages[assistantIndex].copy(
content = lastAssistantMessage.append(token).toString()
)
messages[assistantIndex] = updatedMessage
withContext(Dispatchers.Main) {
adapter.notifyItemChanged(assistantIndex)
messagesView.scrollToPosition(assistantIndex)
}
}
} catch (t: Throwable) {
val assistantIndex = messages.lastIndex
messages[assistantIndex] = messages[assistantIndex].copy(
content = "Error: ${t.message ?: t::class.java.simpleName}"
)
withContext(Dispatchers.Main) {
adapter.notifyItemChanged(assistantIndex)
sending = false
userInput.isEnabled = true
sendButton.isEnabled = true
}
}
}
}
// Applies padding for system bars and the on-screen keyboard.
private fun applyWindowInsets() {
ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
view.setPadding(
systemBars.left,
systemBars.top,
systemBars.right,
max(systemBars.bottom, ime.bottom)
)
insets
}
}
Add the cleanup function to release native resources when the activity is destroyed:
// Releases native resources when the activity is destroyed.
override fun onDestroy() {
if (::engine.isInitialized) {
engine.destroy()
}
super.onDestroy()
}
The MainActivity class now contains all the logic needed to load models, send prompts, and display streamed responses. In the next section, you’ll download a model and run the app on your device.