Tutorial: llmPrompt Compose

Cover Page

This part of the tutorial introduces you to the Android app development environment. You’ll learn some Kotlin syntax and Compose features to build reactive UI declaratively. Let’s get started!

Expected behavior

Posting a prompt and receiving and displaying streamed response:

DISCLAIMER: the video demo shows you one aspect of the app’s behavior. It is not a substitute for the spec. If there are any discrepancies between the demo and the spec, please follow the spec. The spec is the single source of truth. If the spec is ambiguous, please consult the teaching staff for clarification.

Be patient, the app on your device or emulator will be very slow because we’re running on debug mode. Depending on the resources on your development platform and device, it could take several seconds after launch for the app’s first screen to show up.

Preliminaries

Before we start, you’ll need to prepare a GitHub repo to submit your tutorials and for us to communicate your tutorial grades back to you. Please follow the instructions in Preparing GitHub for Reactive Tutorials and Projects and then return here to continue.

If you don’t have an environment set up for Android development, please read the course notes on Getting Started with Android Development first.

Creating an Android Studio project

In the following, replace <YOUR:UNIQNAME> with your uniqname. Google will complain if your Package name is not globally unique. Using your uniqname is one way to generate a unique Package name.

Depending on your version of Android Studio, the screenshots in this and subsequent specs may not look exactly the same as what you see on screen.

In the following, replace YOUR:TUTORIALS with the name of your tutorials folder.

  1. Click New Project in “Welcome to Android Studio” screen (screenshot)
  2. On Phone and Tablet tab, select Empty Activity (NOT No Activity and NOT Empty Views Activity) and click Next (screenshot)
  3. Enter Name: Agent (screenshot, showing all fields below)
  4. Package name: edu.umich.<YOUR:UNIQNAME>.agent 👈👈👈

    replace <YOUR:UNIQNAME> with yours, remove the angle brackets,< >

    Android Studio may automatically change all upper case letters in Name to lower case in Package name. If you prefer to use upper case, just edit the Package name directly.

  5. Save location: put your Agent folder in 👉👉👉 /YOUR:TUTORIALS, where YOUR:TUTORIALS is the name you’ve given your assignment GitHub repo clone in Preparing GitHub for Reactive Tutorials and Projects.
  6. Minimum SDK: leave it as is or choose API 34 (Android 14.0)
  7. Build configuration language: Kotlin DSL (build.gradle.kts)
  8. Click Finish

Subsequently in this and other tutorials, we will use the tag YOUR_PACKAGENAME to refer to your package name. Whenever you see the tag, please replace it with your chosen package name.

Check in to GitHub

Open GitHub Desktop and

If you are proficient with git, you don’t have to use GitHub Desktop. However, we can only help with GitHub Desktop, so if you use anything else, you’re on your own.

:point_right: Go to the GitHub website to confirm that your folders follow this structure outline:

YOUR:TUTORIALS
    |-- Agent
        |-- app
        |-- gradle

If the folders in your GitHub repo does not have the above structure, we will not be able to grade your tutorials and you will get a ZERO.

Android Studio project structure

Set the left or Project pane of your Android Studio window to show your project structure in Android view (screenshot) such that your project structure looks like this:

Theming and UI

One can easily spend a whole weekend (or longer) getting the theme “just right.” It’s best to just leave this folder alone, most of the time.

:point_right: We won’t be grading you on how beautiful your UI looks. You’re free to design your UI differently, so long as all indicated UI elements are functioning as specified and fully visible and non-overlapping on the screen.

loading . . .

If your project pane doesn’t look like the above, wait for Android Studio to finish syncing and building and configuring, which usually take a long time on first load or after any project changes, your project should then be structured per the above.

Gradle build setup

Gradle scipt is the build script for your Android project. Staying in your Project pane, open the file /Gradle Scripts/libs.version.toml. Under the [versions] block, update the following two entries:

# . . .
kotlin = "2.4.0"
composeBom = "2026.06.00"
# . . .

“BoM” stands for “Bill of Materials.” It lists the latest versions of libraries as of the listed date that are compatible with each other.

At the bottom of the file, under the [plugins] section, add the line:

kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref= "kotlin" }

Next, open the /Gradle Scripts/build.gradle.kts (Module:app) file.

:point_right: this is the Module gradle file, listed second in /Gradle Scripts/, not the first listed Project gradle file /Gradle Scripts/build.gradle.kts (Project: agent). At the top of the file add:

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

Inside the plugins block, add the line:

    alias(libs.plugins.kotlinSerialization)

After and outside the android block add:

kotlin {
    compilerOptions {
        jvmTarget.set(JvmTarget.JVM_11)
        optIn.addAll("androidx.compose.material3.ExperimentalMaterial3Api",
            "kotlinx.serialization.ExperimentalSerializationApi")
    }
}

Scroll down until you see the dependencies block near the bottom of the file and add the following lines inside the block, below the other implementation statements:

    implementation("androidx.compose.material:material-icons-core:1.7.8")
    implementation("androidx.compose:runtime:runtime-retain:1.11.3")
    implementation("androidx.navigation:navigation-compose:2.9.8")
    implementation("com.squareup.okhttp3:okhttp:5.3.2")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")

OkHttp is a library from Square Inc. used for network transfers. Kotlin serialization helps with converting Kotlin data structure to/from JSON.

Highlighted dependencies

Android Studio will highlight your newly added implementation dependencies. If you hover over the highlighted depency, for example over androidx.compose.material3:material3:1.4.0-alpha18, a dialog box pops up suggesting, Replace with new library catalog definition for androidx-material You can ignore the recommendation or accept by clicking on it. If you accept, the line will be replaced with:

    implementation(libs.compose.material3)

and if you look into /Gradle Scripts/libs.versions.toml, you’ll see under [versions] a new entry has been added:

# . . .
material3 = "1.4.0"

and under [libraries] the following line has been added:

compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }

You can accept (or not) all the recommendations to convert each of the remaining dependencies with the “new library catalog definitions.”

Should you accept the recommendations, click Sync Now on the right corner of the messsage bar that says Gradle files have changed since last project sync. . . . After a successful Gradle sync, it should then recognize the new libraries and all the new implementation lines should not be highlighted anymore.

compileSDK, minSDK, targetSDK

In the android block of your app build file, set compileSDK to the latest Android API level, e.g., 37. This will give you access to the latest Android libraries and compiler warnings of deprecated features or APIs. The compileSDK only affects the compilation step; the SDK is not bundled with your app.

Set the minSDK to the Android version running on your device or whose API your code depends on (sometimes APIs change signatures between Android versions). You can have compileSDK = 37 (Android 17), but minSDK = 34 (Android 14), for example.

Finally, there’s targetSDK, which is the Android version your app has been tested on. On a device running an Android version higher than your targetSDK, OS behavior (e.g., themes) released after your targetSDK will not be applied to your app. The targetSDK can be set between minSDK and compileSDK, usually it will be set to compileSDK.

Google PlayStore also has a minimum supported version (API Level 35 starting August 31st, 2025). Both minSDK and targetSDK (if different) will be bundled with your app.

Starting with Android 11 (API Level 11 (R)), when new APIs are added to a certain API level, it may also be made available as SDK Extensions to earlier API levels (see references).

Permission, colors, and strings

First we need user’s permission to use the network. In AndroidManifest.xml, before the <application block, add:

        <uses-permission android:name="android.permission.INTERNET"/>

Next we define some additional colors. Open the file /app/kotlin+java/YOUR_PACKAGENAME.ui.theme/Color.kt and add the following colors–some of these we will use in latter tutorials:

val WhiteSmoke = Color(0xFFEFEFEF)
val HeavenWhite = Color(0xFFFEFEFE)
val Gray88 = Color(0xFFE0E0E0)

val Chartreuse = Color(0xFFDFFF00)
val Moss = Color(0xFF526822)
val DarkGreen = Color(0xFF006400)

val Navy = Color(0, 39, 76)
val NavyLight = Color(0x8800274C)

val Maize = Color(255, 203, 5)
val MaizeLight = Color(0x88FFC84C)

val Canary = Color(0xFFFFC107)
val Firebrick = Color(0xFFB22222)

Finally, let’s define some strings. Open the file /app/res/values/strings.xml. Inside the resources block, below the line listing your name="app_name", add:

    <string name="send">Submit</string>
    <string name="model">gemma3:270m</string>
    <string name="message">howdy?</string>
    <string name="instruction">Type a message…</string> 

Agent app

AppViewModel

Let’s start by defining AppViewModel to hold user’s choices and input. In addition to user input, we also store here variables accessed by multiple SwiftUI Views. On Android, a ViewModel is a special construct supported by the Android run time. Composables can share ViewModels. Once a shared ViewModel is created, composables sharing it can simply reach out to the Android runtime to access it. Add the following AppViewModel class to your MainActivity.kt file, outside the MainActivity class:

class AppViewModel(app: Application): AndroidViewModel(app) {
    val rein = Rein("https://mada.eecs.umich.edu", "/llmprompt")

    val model = app.getString(R.string.model)

    val message = TextFieldState(app.getString(R.string.message))
    val instruction = app.getString(R.string.instruction)

    val errMsg = mutableStateOf("")

    var conversation = mutableStateListOf<Chat>()
}

The conversation array will be used to hold prompt and completion exchanges between the user and LLM. It is initialized with mutableStateListOf() factory function, which makes it a SnapshotStateList, an observable version of List. Similarly, the property errMsg is initialized with mutableStateOf() factory function, which makes it an observable variable. When a Jetpack Compose function (a composable) reads the value of an observable variable (the subject), the composable is automatically subcribed to the subject, i.e., it will be automatically recomposed whenever the value of the variable changes. If the subject is updated but the new value is the same as the old value, recomposition will not be triggered (duplicates are removed).

With auto import enabled, as you enter code, Android Studio automatically detects and determines which library to import.

In case of multiple potential matches, Android Studio would have you choose. Most of the time the choice would be rather obvious (pick the one with the word compose since we’re using Jetpack Compose, for example). We always provide a full list of imports as an Appendix to each tutorial spec. Compare your import list against the list in the Appendix when in doubt. Or you could cut and paste all of the imports into your source files before adding any code. If you choose to cut and paste the imports, be sure that you do NOT check Optimize imports on the fly in Android Studio’s Preferences/Settings, otherwise Android Studio will automatically remove them all for being unused and not needed.

Chat

We next define a Chat class to hold the prompt and completion we want to display. Create a new Kotlin File (not Class):

Kotlin Class/File

When you select New > Kotlin Class/File, Android Studio defaults to creating a Kotlin Class, which automatically adds a blank class definition for you, whereas we want an empty file here. So be sure to choose File not Class (screenshot).

Please remember this distinction between creating a Kotlin File vs. Class. You will need to make this distinction in all subsequent tutorials.

  1. Right click on/app/kotlin+java/PACKAGE_NAME folder on the left/project pane
  2. Select New > Kotlin Class/File
  3. Enter ChatView in the Name text field on top of the dialog box that pops up and double click on File (again not Class).
  4. Click Add when prompted whether you want to add it to Git.

add the following Chat struct to the file:

data class Chat(val id: UUID = randomUUID(), // composable ID
                var role: String = "user",   // no observation
                val content: MutableState<String> = mutableStateOf(""),
                var timestamp: String = Instant.now().toString()) // no observation

Compose can use id to uniquely identify each instance of a Chat in a list. When identifiable items in a list moved up or down the list but otherwise not modified, Compose can skip recomposing them.

Rein

We now define the rein to control and communicate with the harness. Create another Kotlin File (not Class) and name it Rein.kt.

We represent the request JSON data format for OpenAI /v1/chat/completions API with an OpenAIRequest structure and its response with an OpenAIResponse structure. Put the following in your Rein.kt:

// OpenAI-compatible request/response JSON formats
@Serializable
data class Message(
    val role: String = "user",
    val content: String = ""    // kotlin serialization cannot serialize MutableState
)

@Serializable
data class OpenAIRequest(
    val model: String,
    //val max_tokens: Int = 8192, // some models require it
    val messages: List<Message>,
    val stream: Boolean = true  // always streaming
)

@Serializable
@JsonIgnoreUnknownKeys
data class Delta(
    val role: String? = null,
    val content: String? = null
)

@Serializable
@JsonIgnoreUnknownKeys
data class Choice(
    val delta: Delta,
)

@Serializable
@JsonIgnoreUnknownKeys
data class OpenAIResponse(
    val model: String? = null,
    val choices: List<Choice>,   // guaranteed only one completion choice
    val created: Long? = null
)

enum class SseEvent { Error, Message }

The @Serializable annotation generates serialization code to convert Kotlin data types into JSON strings and conversely convert JSON strings received from the network into Kotlin data types.

We also force streaming of the completion tokens, which will be streamed using the Server-Sent Event (SSE) protocol. Our agent recognizes two SSE events, as encoded in the SseEvent enum class.

We now define the Rein class:

class Rein(
    val harness: String,
    val api: String,
    var apiKey: String? = null // WARNING: vulnerable to memory dump and man-in-the-middle attacks
) {
    private val JSONEncoder = Json {
        encodeDefaults = true // forces inclusion of all properties
    }
    
    // OkHttpClient
  
}

Rein control methods communicate with the back-end harness at the provided harness url and api end point. You can use apiKey when connecting the agent directly to a LLM runner instead of through our back-end harness.

Connecting directly to an LLM runner

WARNING: NOT RECOMMENDED FOR SECURITY REASON

Third-party LLM runners, such as those from OpenAI, Anthropic, OpenRouter, etc., usually require an API key to use. Storing your API key on device is not recommended for security reasons:

  1. on mobile platforms, there is no equivalent to back-end dotenv libraries that load environment variables at run time. Instead such variables are usually stored in a local config file loaded to your app’s memory at build time. In-memory variables are vulnerable to memory dump. Mobile devices are considered less secure than hardened back-end servers because they are easier for an attacker to gain access, and
  2. an attacker with access to your device can also mount a man-in-the-middle attack and spoof your server’s certificate to obtain your API key.

Instead, we’ve adopted the practice of keeping your API key in back-end server’s environment variables.

We use the OkHttp library for network communication with the back end. We first build a reusable OkHttpClient, with connect, read, and write timeouts open for long-lived SSE streams. With OkHttpClient, we get a callback-based Call API. To maintain a uniform, coroutine-based codebase, we add an await() extension function to OkHttpClient’s Call interface. This extension bridges the two APIs by registering a set of callback functions that explicitly resumes the suspending function’s continuation. OkHttpClient invokes the appropriate callback function upon completion of the enqueue() function of the Call interface. The await() extension further ensures that if the calling coroutine is cancelled, the underlying network request is immediately aborted—which triggers the onFailure() callback; in this case, we guard against calling resumeWithException when the coroutine (and its continuation) has been cancelled.

Add the following code to your Rein class, replacing the comment // OkHttpClient:

    private val client = OkHttpClient.Builder()
        // no timeouts: https://www.baeldung.com/okhttp-timeouts
        .connectTimeout(0, TimeUnit.MILLISECONDS)
        .readTimeout(0, TimeUnit.MILLISECONDS)
        .writeTimeout(0, TimeUnit.MILLISECONDS)
        .build()
    private suspend fun Call.await() = suspendCancellableCoroutine { cont ->
        enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                cont.resume(response)
            }
            override fun onFailure(call: Call, e: IOException) {
                if (cont.isCancelled) return // guards against resuming a cancelled coroutine
                cont.resumeWithException(e)
            }
        })

        cont.invokeOnCancellation {
            this@await.cancel() // i.e., Call.cancel(), triggers onFailure()
        }
    }
  
    // rein control
  

To send a prompt to the harness, the user calls the suspending function llmPrompt(). Replace // rein control comment above with the following function definition:

    suspend fun llmPrompt(vm: AppViewModel, messages: List<Message>, completion: Chat) {
        val harnessApi = "${harness}${api}"
        
        // prepare prompt
    }

The harness in this tutorial serves as a straight-through proxy for the LLM runner. We can thus send our prompt requests using OpenAI-compatible /v1/chat/completions API’s JSON data format:

{
    "model": string,
    "messages": [
        {
            "role": string,
            "content": string
        }
    ],
    "stream": boolean
}

We first assemble an instance of Kotlin OpenAIRequest data class comprising the expected key-value pairs. We can’t just post the Kotlin data class as is though. The server may not be, and actually is not, written in Kotlin, and could have a different memory layout for various data structures. Presented with a chunk of binary data, the server will not know that the data represents a data class, nor how to reconstruct the data class in its memory layout. To post the Kotlin data class, we first call Json.encodeToString() to serialize the data class into a JSON string. We then put this JSON string in a requestBody. Add the following code to your llmPrompt(), replacing // prepare prompt:

        val openAIRequest = OpenAIRequest(
            model = vm.model,
            messages = messages
        )
        val requestBody = JSONEncoder.encodeToString(openAIRequest)
            .toRequestBody("application/json; charset=utf-8".toMediaType())

        // prepare request
      

Next we create an HTTP POST request using Request to carry the requestBody. Replace // prepare request with the following code:

        val request = Request.Builder()
            .url(harnessApi)
            .addHeader("Accept", "text/event-stream")
            .apply { // vulnerable to memory dump and man-in-the-middle attacks
                apiKey?.let {
                    addHeader("Authorization", "Bearer $it")
                }
            }
            .post(requestBody)
            .build()
        
        // connect to harness and send request
      

We initiate a connection to our harness back end and check that the connection has been made successfully. If we fail to connect to our back end (the catch block) or the harness returned any HTTP error, we simply report it to the user and end session, otherwise we collect the streamed completion. Replace // connect to harness and send request with:

        try {
            val response = client.newCall(request).await()
            if (!response.isSuccessful) {
                vm.errMsg.value = "${response.code}\n$harnessApi\n${response.body.string()}"
                return
            }

            collectCompletion(response.body.source(), completion, vm.errMsg)
            
        } catch (e: Throwable) {
            vm.errMsg.value = "llmPrompt: ${e.localizedMessage ?: "failed"}"
        }     

The errMsg property is of type MutableState<String>, which means that updating its value will notify observers of the change. We’ll see later that updating vm.errMsg causes an alert dialog box to pop up, to warn the user.

We now parse the incoming SSE stream to collect the returning completion. Add the following collectCompletion() helper method to your Rein class:

    suspend fun collectCompletion(stream: BufferedSource, completion: Chat, errMsg: MutableState<String>) {
    
        var sseEvent = SseEvent.Message
        while (!stream.exhausted()) {
            val line = stream.readUtf8Line() ?: continue
    
            if (line.isEmpty()) {
                if (sseEvent == SseEvent.Error) {
                    completion.content.value += "\n\n**llmPrompt SSE Error**: ${errMsg.value}\n\n"
                }
    
                // new SSE event, default to Message
                // SSE events are delimited by '\n\n'
                sseEvent = SseEvent.Message
                continue
            }

            // parse SSE line

        }
    }

When an empty line is detected, if we are in an Error event block, we report the error on the timeline (which pops up an alert dialog box in the UI). Then we reset the event to the default Message event.

If the next line starts with the text event, we’re starting a new named event block, otherwise, it’s a data line and we handle (save) it depending on the event it’s associated with. By the SSE spec, when unspecified, Message is the default event name.

Each SSE line is a string, no quotation marks are used to further indicate that it is a string. OpenAI’s /v1/chat/completions API further requires that either [DONE] or a serialized JSON object follows a data: tag. Replace the comment // parse SSE line above with:

                // parse SSE line
                val parts = line.split(":", limit = 2)
                if (parts.size != 2) {
                    // should not happen, debug print
                    Log.d("LLMPROMPT", "malformed SSE line: $line")
                    continue
                }
                val tag = parts[0]
                val tagline = parts[1].trim()
                if (tag == "event") {
                    // https://medium.com/@manoel.al.amaro/understand-kotlin-flow-coroutines-by-implementing-server-side-sent-sse-9e190ff5f24f
                    if (tagline == "error") {
                        sseEvent = SseEvent.Error
                    } else if (!tagline.isEmpty() && tagline != "message") {
                        // we only support "error" event, "message"
                        // events are assume implicit by the SSE spec.
                        // should not happen, debug print
                        Log.d("LLMPROMPT", "Unknown event: '${parts[1]}'")
                    }
                } else if (tag == "data") {
                    // not an event line, we only support data line;
                    if (tagline.isEmpty() || tagline == "[DONE]") {
                        // OpenAI's `/v1/chat/completions` uses "data: [DONE]"
                        // to indicate end of strea
                        continue
                    }
                    // multiple data lines can belong to the same event
                    try {
                        // OpenAI's `/v1/chat/completions` API requires that
                        // a serialized JSON object follows a `data:` tag.
                        val openAIResponse = Json.decodeFromString<OpenAIResponse>(tagline)
    
                        // extract content from choices[0].delta.content.
                        // we specified no alternative completion earlier
                        // with OpenAIRequest.n = 1
                        openAIResponse.choices.firstOrNull()?.delta?.content?.let { token ->
                            if (token.isNotEmpty()) {
                                if (sseEvent == SseEvent.Error) {
                                    errMsg.value += token
                                } else {
                                    completion.content.value += token
                                    if (completion.timestamp.isEmpty()) {
                                        openAIResponse.model?.let { 
                                            completion.role = "assistant ($it)"
                                        }
                                        val created = openAIResponse.created ?: (System.currentTimeMillis() / 1000)
                                        completion.timestamp = Instant.ofEpochSecond(created).toString()
                                    }                                    
                                }
                            }
                        }
                    } catch (e: IllegalArgumentException) {
                        errMsg.value += "${e.localizedMessage}\n$harness$api\n$line"
                    }
                }
                

We are done with the Rein our agent uses to control and communicate with the harness and LLM. We now turn to the UI user uses to interact with the agent.

MainActivity

Returning to MainActivity.kt, delete the composable functions Greeting() and GreetingPreview(). Replace your MainActivity class definition with the following: ```kotlin class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge()

    setContent {
        MainView()
    }
} } ``` As a general rule, a `@Composable` function can only be called by another `@Composable` function. The only exception is `setContent()`, which binds the given composable to an `Activity` as the content view of the `Activity`. It is the only non-composable allowed to call a composable.
@Preview and LiveEdit

When Android Studio created MainActivity.kt it also put in it a @Preview block. As the name implies, the @Preview code allows you to preview your composables. The preview only renders your composable, it is not an emulator: it won’t populate your composable with data. Given the simple, single-page nature of our apps, I found the preview to be of limited use and would just delete or comment out the whole @Preview block, which automatically disables the preview and closes the Design (or Split) pane. The video, Compose Design Tools, shows you what is possible with @Preview. Also check out the @Preview section of the References below.

Android Studio Giraffe and higher supports LiveEdit which “update composables in emulators and physical devices in real time.” While @Preview allows you to see your UI design in different themes, locales, and UI element settings but does not actually run the rest of your app, LiveEdit updates your actual running app. “Live Edit is focused on UI- and UX-related code changes. It doesn’t support changes such as method signature updates, adding new methods, or class hierarchy changes.”

ConversationView

We want to display user exchanges with the LLM runner in a conversation view. First we define what each row of the conversation contains. Go back to your ChatView.kt file and add the following composable:

@Composable
fun ChatView(chat: Chat, onTrailingEnd: Boolean) {
    Column(
        horizontalAlignment = if (onTrailingEnd) Alignment.End else Alignment.Start,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp)
    ) {
            // chat displayed here
    }
}

For each chat, we check whether we’re displaying the user’s message or a response from the LLM. In the former case, we display the row flushed to the trailing/end edge of the screen, else flushed to the other edge. The modifier fillMaxWidth() forces the Column to use the full width of the screen.

If your locale has a language that reads left to right, start is left; otherwise, start is right (conversely, end). Most of the time you would use start and end to refer to the two ends of a UI element, reserving left and right for use with the physical world, e.g., when giving direction.

Below we check if the message is empty. If it’s not empty, we first display the model used if it’s from the “assistant” (LLM). Then we display the message in a “message bubble,” followed by the timestamp on the message. We put these three elements inside a Column which arranges its elements in a vertical stack. Add the following lines inside your Column(){} block, replacing // chat displayed here:

        val msg = chat.content
        if (msg.value.isNotEmpty()) {
            Text(
                text = if (onTrailingEnd) "" else chat.role,
                style = MaterialTheme.typography.labelLarge,
                color = PurpleGrey40,
                modifier = Modifier
                    .padding(start = 4.dp)
            )
        
            Text(
                text = msg.value,
                style = MaterialTheme.typography.bodyLarge,
                modifier = Modifier
                    .shadow(2.dp, shape = RoundedCornerShape(20.dp))
                    .background(if (onTrailingEnd) Chartreuse else HeavenWhite)
                    .padding(12.dp)
                    .widthIn(min = 50.dp, max = 350.dp)
            )
        
            Text(
                text = chat.timestamp,
                color = Color.Gray,
                style = MaterialTheme.typography.labelSmall,
                modifier = Modifier
                    .padding(top = 4.dp, start = 8.dp)
            )
        }
      

Hover over the red elements and import any missing classes. Usually the first choice with compose in its package name is the right one.

“dp”, “px”, “sp”

Aside from different screen sizes, different Android devices also have different screen densities, i.e., number of pixels per inch. To ensure UI elements have more-or-less uniform sizes on screens with different densities, Google recommends that sizes be expressed in terms of device-independent pixels (dp) which is then displayed using more or less pixels (px) depending on screen density (see also Cracking Android Screen Sizes and Designing for Multiple Screen Densisites on Android).

Text sizes are measured in sp (scale-independent-pixel) unit, which specifies font sizes relative to user’s font-size preference—to assist visually-impaired users.

You can hover over a composable (e.g., Column, Text, or Scaffold) to bring up a menu of possible actions on it.

DSL

Notice how type inference and the use of trailing lamba makes Row, Column, Text, etc. look and act like keywords of a programming language, separate from Kotlin. Thus Compose is considered a “domain-specific language (DSL),” the “domain” in this case being UI description.

Now that we have a description of each row, we can put the rows in a list. Put the the following View in your ChatView.kt file, outside the ChatView composable:

@Composable
fun ConversationView(modifier: Modifier, listScroll: LazyListState) {
    val vm: AppViewModel = viewModel()

    LazyColumn(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(12.dp),
        state = listScroll,
    ) {
        // import androidx.compose.foundation.lazy.items
        items(items = vm.conversation, key = { it.id as Any }) {
            ChatView(it, it.role == "user")
        }
    }
}

The variable listScroll is used to scroll the list programmatically. Here we’re simply assigning the provided LazyListState to the LazyColumn. We will use it in SubmitButton later to scroll the list programmatically.

For each element in the conversation array , ChatView constructs and returns a composable, which LazyColumn then displays. LazyColumn only loads array elements that are visible on screen. The function items() recomposes an item iff the key of that item has changed—for example, if a row’s chat.id has not changed, the row will not be recomposed even if it gets moved around the array. Without id, each row will be recomposed if it moves up or down in the array even if the content of the row hasn’t changed.

Recall that the conversation array is a MutableStateList. When a composable accesses conversation, Compose automatically subscribes the composable to the conversation property so that the composable can be automatically recomposed whenever conversation is modified. ConversationView helps ChatView determine which edge of the screen to display a chat by comparing the role property of the chat.message against the string "user".

SubmitButton

SubmitButton sends each user’s prompt to the back end and receives the LLM’s completion. Create another new Kotlin File, name it MainView.kt, and put the following code in it:

@Composable
fun SubmitButton(listScroll: LazyListState) {
    val vm: AppViewModel = viewModel()

    var isSending by retain { mutableStateOf(false) }
    // `by` property delegation needs to import `setValue`
    // and `getValue` separately, so do import twice
    // If Android Studio doesn't give you any import option, wait
    // a few minutes, usually it will then find the imports.

    IconButton(
        onClick = {
            isSending = true
            
            val chat = Chat(content = 
                mutableStateOf(vm.message.text.toString())
            )
            vm.conversation.add(chat)
            
            // pass chat to harness in an array of `messages`
            // but kotlin serialization cannot serialize MutableState
            // so must manually convert Chat.content.value to Message.content
            val messages = listOf(Message(chat.role, chat.content.value))

            // prepare completion placeholder
            val completion = Chat(role = "assistant (${vm.model})",
                    content = mutableStateOf(""), timestamp = "") // placeholder for assistant's streaming completion
            vm.conversation.add(completion)

            vm.viewModelScope.launch(Dispatchers.IO) {
                vm.rein.llmPrompt(vm, messages, completion)
                
                // continuation code
                
            }
        },
        // modifiers
        
    ) {
        // icons

    }
}

When the button is clicked, we set isSending to true and add the user prompt to conversation array. We want each arriving stream element to be displayed right away. To hold the response stream, we create a placeholder completion: Chat and append it also to the conversation array. Then we call llmPrompt(), passing it the user prompt. Compose will later reactively display the updated conversation array. The function llmPrompt() expects the user prompt to be put in an array of messages. We launch llmPrompt() using vm.viewModelScope so that our network operations survives the composable and Activity lifecycles, which got terminated when you change the orientation of your device, for example.

remember and retain

As previously discusssed, to make a variable observable, we give it a MutableState type using mutableStateOf() or mutableStateListOf(). A composable that accesses an observable variable automatically subscribes to it. You don’t need to use remember() to subscribe to a published variable. Use remember() only if you want to retain states declared in composables across recompositions. This is normally used for view-logic states.

Other than recomposition, states in a composable can also be destroyed by events impacting Activity lifecycle, such as device-orientation change. To save composable states across changes in orientation, we use the new retain() Compose API.

There’s also the more established rememberSaveable() Compose AI that serializes states and stores them to Android OS memory whenever the app is moved to the background. This allows developers to (1) opt to retain states when switching between screens of a bottom navigation tab and (2) manually restore states when the app restarts after being evicted by the system due to low available memory.

States in a singleton object and in ViewModels are not destroyed across recompositions, nor when switching between screens of a bottom navigation tab. ViewModels can be explicitly saved and restored using SavedStateHandle to survive process death.

There are three ways we can use remember():

  1. Directly assign a remembered mutable state to a variable:
    val message = remember { mutableStateOf("A message") } // type: MutableState<String>
    

    the variable will then be of type MutableState<T>. To modify the value stored in the variable, we modify it’s value property, e.g., message.value = "Another message". This method is required to specify MutableState<T> as a function or (data)class parameter.

  2. Use property delegation (by):
    val message by remember { mutableStateOf("A message") } // type: String
    

    we implicitly cast the state object as object of type T in Compose, which makes working with the variable more convenient, e.g., we can directly assign a literal value of type T (String in this case) to the variable. This is the most convenient method when it is applicable.

  3. Or destructure the remembered state into its getter and setter:
    val (message, setMessage) = remember { mutableStateOf("A message") }
    

    The getter (message) is then used to read the state and its setter (setMessage) to set the state. When used with legacy version of TextField(), its value parameter will be assigned the getter, message, and its onValueChange parameter assigned the setter, setMessage. Note that destructured message is not updated (it’s a val) when setMessage is called. If the assignment is inside a composable function message is re-initialized during recomposition so it looks like it is updated.

Upon returning from llmPrompt(), we reset vm.message and isSending. Then we scroll the display to the bottom of the displayed conversation using animateScrollToItem. Add the following code inside the launch {} block, replacing the comment // continuation code:

                // cleanup
                vm.message.clearText()
                isSending = false
                listScroll.animateScrollToItem(vm.conversation.size)
              

To disable the button if isSending is true or if there’s no message to send, add the following modifiers to IconButton by replacing the comment // modifiers:

        modifier = Modifier
            .size(55.dp)
            .background(if (vm.message.text.isEmpty()) NavyLight else Navy,
                shape = CircleShape),
        enabled = !(isSending || vm.message.text.isEmpty()),
      

For the icon itself, we provide two options: one to show a “loading” view if we’re waiting for response from the LLM (isSending is true) and one to show a “paperplane” submit icon otherwise. Add the following code as IconButton’s content parameter, replacing the comment // icons:

        if (isSending) {
            CircularProgressIndicator(
                color = Gray88,
                strokeWidth = 4.dp,
                modifier = Modifier.size(24.dp)
            )
        } else {
            Icon(
                Icons.AutoMirrored.Filled.Send,
                contentDescription = stringResource(R.string.send),
                tint = if (vm.message.text.isEmpty()) MaizeLight else Maize,
                modifier = Modifier.size(28.dp)
            )
        }
      

Again, hover over the red elements and import any missing classes. Usually the first choice with compose in its package name is the right one. For Instant, you need to import java.time.Instant instead of kotlin.time.Instant. Property delegation using by requires import of androidx.compose.runtime.setValue and androidx.compose.runtime.getValue as two separate import action, so you’d need to hover over by and do the import twice.

MainView

We now have all the pieces we need to build our MainView. Add to your MainView.kt, outside the SubmitButton:

@Composable
fun MainView() {
    val vm: AppViewModel = viewModel()

    val layoutDirection = LocalLayoutDirection.current
    val listScroll = rememberLazyListState()
    val focus = LocalFocusManager.current

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(
                        text = stringResource(R.string.app_name),
                        fontSize = 20.sp,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.fillMaxWidth(),
                        textAlign = TextAlign.Center
                    )
                },
                colors = TopAppBarDefaults.topAppBarColors(WhiteSmoke),
            )
        },
        // tap background to dismiss keyboard
    ) {
        // describe the content
    }
}

We initialize a LazyListState that we will pass to ConversationView to control the scrolling of the LazyColumn it instantiates. The scrolling itself is done in SubmitButton.

Scaffold is a composable that implements the basic Material Design visual layout structure, providing slots for the most common top-level components such as topbar, floating action button, and others. By using Scaffold, we ensure the proper positioning of these components and that they interoperate smoothly. Scaffold, TopAppBar are examples of layout composables that follow the slot-based layout, a.k.a. Slot API pattern of Compose. In our case, we add a TopAppBar that consists of only a textbox containing the string llmPrompt.

Why topBar = { TopBar() }?

The topBar parameter of Scaffold take as argument a composable with zero parameter and Unit return value. In Kotlin, a function is not a reference, to assign the composable function TopAppBar() to topBar, we must wrap it in a parameterless lambda returning Unit value, which we did above.

When the user taps anywhere on the screen other than the TextField below, we want to dismiss the soft keyboard. Replace // tap background to dismiss keyboard with:

        modifier = Modifier
            .pointerInput(Unit) {
                detectTapGestures { focus.clearFocus() }
            }
          

Scaffold’s last parameter, content, also takes a composable as argument. Since it is the last argument, we have presented it as a trailing lambda in the above. We’ll show the conversation here. Replace // describe the content with the following code:

        Column(
            modifier = Modifier
                .fillMaxSize()
                .imePadding()
                .padding(
                    it.calculateStartPadding(layoutDirection),
                    it.calculateTopPadding(),
                    it.calculateEndPadding(layoutDirection),
                )
                .background(color = WhiteSmoke),
        ) {
            ConversationView(modifier = Modifier.weight(1f), listScroll)
            
            // prompt input and submit
        }
      

The modifier imePadding() causes the Column to not vertically extend behind the soft keyboard. The padding() modifier sets padding according to the safe area of the screen, but let the bottom padding extends to the edge of the screen. Then MainView puts the ConversationView at the top of its column.

Below ConversationView, we now put a text box, where user can enter their LLM prompt, and the SubmitButton. We put these text box and button inside a Row. Elements in a Row are displayed side by side horizontally. Replace // prompt input and submit with:

            Row(horizontalArrangement = Arrangement.SpaceEvenly,
                verticalAlignment = Alignment.CenterVertically,
                modifier = Modifier
                    .fillMaxWidth(1f)
                    .imePadding()
                    .background(color = WhiteSmoke)
                    .padding(top = 4.dp, start = 20.dp, end = 20.dp, bottom = 40.dp)
            ) {
                OutlinedTextField(
                    state = vm.message,
                    placeholder = {
                        Text(text = vm.instruction, color = Color.Gray)
                    },
                    shape = RoundedCornerShape(40.dp),
                    modifier = Modifier
                        .weight(1f)
                        .padding(end = 12.dp),
                    textStyle = LocalTextStyle.current.copy(fontSize = 18.sp),
                    colors = TextFieldDefaults.colors(
                        unfocusedContainerColor = HeavenWhite,
                        focusedContainerColor = HeavenWhite,
                        focusedIndicatorColor = Color.Transparent,
                        unfocusedIndicatorColor = Color.Transparent
                    ),
                    lineLimits = TextFieldLineLimits.MultiLine(1, 6),
                )
                SubmitButton(listScroll)
            }

            // show error
          

OutlinedTextField can modify vm.message as if it were passed by reference. We also give vm.instruction to OutlinedTextField, which will be shown as a “background” text that automatically goes away when the user starts typing. We also pass listScroll to SubmitButton so that it can programmatically scroll the screen to the last item it added to the conversation array.

Before we leave MainView, we check whether vm.errMsg is empty. If it’s not empty, we show an alert dialog with the content of vm.errMsg as the error message. Replace // show error with:

            if (vm.errMsg.value.isNotEmpty()) {
                AlertDialog(
                    modifier = Modifier
                        .shadow(0.dp, shape = RoundedCornerShape(20.dp))
                        .padding(12.dp)
                        .widthIn(min = 30.dp, max = 300.dp),
                    onDismissRequest = {
                        vm.errMsg.value = ""
                    },
                    title = {
                        Text(
                            "LLM Error",
                            fontWeight = FontWeight.Bold
                        )
                    },                    
                    text = {
                        Text(
                            vm.errMsg.value,
                            fontSize = 20.sp,
                            lineHeight = 24.sp,
                            letterSpacing = 0.5.sp
                        )
                    },
                    confirmButton = {
                        TextButton(onClick = {
                            vm.errMsg.value = ""
                        }) {
                            Text(
                                "OK",
                                fontSize = 28.sp,
                                fontWeight = FontWeight.Bold,
                            )
                        }
                    },                    
                )
            }
          

Again, hover over the red elements and import any missing classes. Usually the first choice with compose in its package name is the right one.

Congratulations! You’re done with the front end! (Don’t forget to work on the back end!)

Run and test to verify and debug

You should now be able to run your front end against the provided back end on mada.eecs.umich.edu.

If you’re not familiar with how to run and test your code, please review the instructions in the Getting Started with Android Development.

Completing the back end

Once you’re satisfied that your front end is working correctly, follow the back-end spec to build your own back end:

With your back end completed, return here to prepare your front end to connect to your back end via HTTP/2 with HTTPS.

Installing your self-signed certificate

Download a copy of your harnessd.crt to /YOUR:TUTORIALS/ folder on your laptop. Enter the following commands:

laptop$ cd /YOUR:TUTORIALS/
laptop$ scp -i agentic.pem ubuntu@YOUR_SERVER_IP:agentic/harnessd.crt harnessd.crt

Install harnessd.crt onto your Android: download harnessd.crt from your laptop onto your emulator or device:

You can verify that your certificate is installed in Settings > Security & privacy > More security settings > Encryption & credentials > User credentials.

To test the installion, launch a web browser on the emulator or device and access your server at https://YOUR_SERVER_IP/ (you’d have to enable your / API to do this test).

Preparing Agent

Next, we need to tell your app to trust the self-signed certificate.

On the left pane in Android Studio, right click the xml folder in /app/res/, choose New > File in the drop-down menu. Name the new XML file network_security_config.xml and put the following content in the file:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config>
        <!--                            👇👇👇👇👇👇👇👇👇 -->
        <domain includeSubdomains="true">YOUR_SERVER_IP</domain>
        <trust-anchors>
            <certificates src="user"/>
        </trust-anchors>
     </domain-config>
</network-security-config>

WARNING: be sure to limit the use of the self-signed certificate to your back-end server IP as shown above. In particular use <domain-config> instead of the <base-config> tag because the latter will cause your app to try and fail to apply the self-signed certificate with other services, such as Google Maps.

Add the following line that accounts for the new file to your AndroidManifest.xml, above the existing android:theme line:

<application
    <!-- ... other items -->
    android:networkSecurityConfig="@xml/network_security_config"
    android:theme="@style/Theme.Agent">

Finally, change the harness property of your Rein instantiation in MainActivity.kt from https://mada.eecs.umich.edu to https://YOUR_SERVER_IP. Build and run your app and you should now be able to connect your mobile front end to your harness via HTTPS. Your front end must work with both mada.eecs.umich.edu and your own harness.

:point_right:You will not get full credit if your submitted front end is not set up to work with your harness!

Front-end submission guidelines

We will only grade files committed to the main branch. If you’ve created multiple branches, please merge them all to the main branch for submission.

Push your front-end code to the same GitHub repo you’ve submitted your back-end code:

:point_right: Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo under the folder agent. Confirm that your repo has a folder structure outline similar to the following. If your folder structure is not as outlined, our script will not pick up your submission, you will get ZERO point, and you will have problems getting started on latter tutorials. There could be other files or folders in your local folder not listed below, don’t delete them. As long as you have installed the course .gitignore as per the instructions in Preparing GitHub for Reactive Tutorials and Projects, only files needed for grading will be pushed to GitHub.

YOUR:TUTORIALS
    |-- harnessd
    |-- harnessd.crt  
    |-- Agent
        |-- app
        |-- gradle

Verify that your Git repo is set up correctly: on your laptop, grab a new clone of your repo and build and run your submission to make sure that it works. You will get ZERO point if your tutorial doesn’t open, build, or run.

IMPORTANT: If you work in a team, put the names and uniqnames of all members in your repo’s README.md so that we’d know (click the pencil icon at the upper right corner of the README.md box on your git repo to edit). Otherwise, we could mistakenly think that you were cheating and accidentally report you to the Honor Council, which would be a hassle to undo. You don’t need a README.md if you work by yourself.

Invite eecsreactive@umich.edu to your GitHub repo. Enter your uniqname (and that of your team mates’) and the link to your GitHub repo on the Tutorial and Project Links sheet. The request for teaming information is redundant by design.

References

Dev

OkHttp3

SSE

Jetpack Compose Concepts

Tutorial Pathways

Documentations

3rd-party articles on Jetpack Compose

Depending on date of publication, Compose APIs used in 3rd-party articles may have been deprecated or their signatures may have changed. Always consult the authoritative official documentation and change logs for the most up to date version.

Screen sizes and densities

Layout and Components

ViewModel

ConstraintLayout and Compose

LiveEdit and @Preview

Themes and Styles

Appendix: imports


Prepared by Alex Wu, Tiberiu Vilcu, Nowrin Mohamed, Chenglin Li, Xin Jie ‘Joyce’ Liu, and Sugih Jamin Last updated: June 21st, 2026