Tutorial: llmPrompt Compose

Cover Page

DUE Wed, 09/03, 2 pm

This tutorial introduces you to the Android app development environment and basic development tools on the backend. You’ll learn some Kotlin syntax and language features for the front end and, to a lesser extent, get acquainted with the backend language and web stack of your choice. You will use Compose to build reactive UI declaratively on the front end. 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 Chatter app on your device or emulator will be very slow because we’re running on debug mode, not as stand-alone app in release 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 our 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.

  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: composeChatter (screenshot, showing all fields below)
  4. Package name: edu.umich.<YOUR UNIQNAME>.composeChatter 👈👈👈

    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 composeChatter folder in 👉👉👉 YOUR*TUTORIALS/llmprompt/composeChatter/, where YOUR*TUTORIALS is the name you give to your assignment GitHub repo clone in Preparing GitHub for Reactive above.
  6. Minimum SDK: API 36 (Android 16.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.

Checking 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’ll be on your own.

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

  reactive
    |-- llmprompt   
        |-- composeChatter
            |-- 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 fully visible on the screen, non overlapping, and functioning as specified.

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 of 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.2.10"
# . . .
compose-bom = "2025.08.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: composeChatter). Subsequently, we will refer to /Gradle Scripts/build.gradle.kts (Module:app) as the “app build file”.

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.material3:material3:1.4.0-beta02")
    implementation("androidx.compose.material:material-icons-core:1.7.8")
    implementation("androidx.navigation:navigation-compose:2.9.3")
    implementation("com.squareup.okhttp3:okhttp:5.1.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")

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

compileSDK, minSDK, targetSDK

In the android block of your app build file, set compileSDK to the latest Android API level, e.g., 36. 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 = 36 (Android 16), but minSDK = 31 (Android 11), for example. We will set minSDK = 36 also. 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 to 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 also references).

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-alpha18"

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.

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"/>

We also want to allow dismissing of the soft keyboard when changing focus. In the <activity block, under android:exported="true", add:

            android:windowSoftInputMode="adjustResize"

Next we define some additional colors. Open the file /app/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 app_name, add:

    <string name="chatter">llmPrompt</string>
    <string name="send">Send</string>
    <string name="model">tinyllama</string>
    <string name="username">tinyllama</string> <!-- instead of uniqname -->
    <string name="message">howdy?</string>
    <string name="instruction">Type a message…</string> 

Chatter app

Chatt

In all our tutorials, we will use a structure called Chatt to hold exchanges with the backend to be displayed on screen. We store the definition of this structure in a file called Chatt.kt. Create a new Kotlin File (not Class):

  1. Right click on/app/kotlin+java/PACKAGE_NAME folder on the left/project pane
  2. Select New > Kotlin Class/File
  3. Enter Chatt in the Name text field on top of the dialog box that pops up and double click on File (again not Class (screenshot))

    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”.

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

  4. A chatt holds at the minimum the following fields. When the Chatter app is used to interact with Ollama, the username field may be used to hold the LLM model instead of the actual user’s name. Similarly, the message field will be used to hold user’s prompt to Ollama in such use case. Place the following class definition for Chatt in the newly created file:
     class Chatt(var username: String? = null,
                 var message: MutableState<String>? = null,
                 var id: UUID? = randomUUID(),
                 var timestamp: String? = null)
    

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

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 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.

ChattStore

Create another Kotlin File (not Class), call it `ChattStore.kt.

While the frontend sends messages to the backend in the form of Chatt messages, Ollama can either response with OllamaError or OllamaReply. Put the following in your ChattStore.kt:

@Serializable
data class OllamaError(val error: String)

@Serializable
@JsonIgnoreUnknownKeys
data class OllamaReply(val model: String, val created_at: String, val response: String)

The @Serializable annotation generates code to convert JSON strings received from the network into these Kotlin classes.

Then add the following ChattStore singleton:

object ChattStore {
    var chatts = mutableStateListOf<Chatt>()
        private set

    private const val serverUrl = "https://YOUR_SERVER_IP"
}

Once you have implemented your own back-end server, you will replace mada.eecs.umich.edu with your server’s IP address.

Declaring ChattStore an object makes it a singleton, meaning there will ever be only one instance of this class when the app runs. We will keep user’s interaction with Ollama in the chatts array. Since we want only a single copy of the chatts data, we make this a singleton object.

The array chatts is initialized with a mutableStateListOf, which is of type SnapshotStateList, an observable version of List. 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 notified and the composable will automatically recompose when the value 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). The chatts array will be used to hold user exchanges with Ollama. While we want chatts to be readable outside the class, we don’t want it publicly modifiable, and so we have set its “setter” to private.

Since chatts are retrieved from and posted to the chatterd back-end server, we will keep all network functions to communicate with the server as methods of this class. We use the OkHttp library for network communication. This library does not have a built-in suspending coroutine for initiating network request, though we can easily write one. Add the following code to your ChattStore class:

    private val client = OkHttpClient.Builder()
        .connectTimeout(0, TimeUnit.MILLISECONDS)
        .readTimeout(0, TimeUnit.MILLISECONDS)
        .writeTimeout(0, TimeUnit.MILLISECONDS)
        .build()
    private suspend fun Call.await() = suspendCoroutine { cont ->
        enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                cont.resume(response)
            }
            override fun onFailure(call: Call, e: IOException) {
                cont.resumeWithException(e)
            }
        })
    }

Aside from adding a suspending extension function, await(), to OkHttp’s Call interface, we also instantiated an OkHttpClient which we’ll reuse for all of our network calls in the app. For this tutorial, we will have only one network function, llmPrompt().

To send a prompt to the backend, the user calls the suspending function llmPrompt(), which starts by appending the user’s prompt to the chatts array. Add the following function definition to your ChattStore class:

    suspend fun llmPrompt(chatt: Chatt, errMsg: MutableState<String>) {

        chatts.add(chatt)

        // prepare prompt
    }

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

For this tutorial, we interact with Ollama using its generate API. Ollama’s generate API expects incoming prompts to be JSON objects with the following fields:

{
    "model": "string",
    "prompt": "string",
    "stream": boolean
}

We use the data passed in through chatt to create such a JSON object for Ollama. Add the following code to your llmPrompt(), replacing // prepare prompt:

        val jsonObj = mapOf(
            "model" to chatt.username,
            "prompt" to chatt.message?.value,
            "stream" to true,
        )
        val requestBody = JSONObject(jsonObj).toString()
            .toRequestBody("application/json; charset=utf-8".toMediaType())

        // prepare request

We first assemble a Kotlin map comprising the key-value pairs of data we want to post to the server. We can’t just post the Kotlin map as is though. The server may not, and actually is not, written in Kotlin, and in any case 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 map, nor how to reconstruct the map in its own map layout. To post the Kotlin map, therefore, we first call JSONObject() to encode it into a serialized JSON object that the server will know how to parse. Then we put it a stringify version in a requestBody.

Below we use the requestBody to build a POST Request, with the appropriate URL. Add the following code to the function, replacing // prepare request:

        val apiUrl = "${serverUrl}/llmprompt"
        val request = Request.Builder()
            .url(apiUrl)
            .addHeader("Accept", "application/*")
            .post(requestBody)
            .build()

        // connect to chatterd and Ollama

We initiate a connection to our chatterd backend and send the request. Our backend simply forwards the request to Ollama. Check that the connection has been made successfully. If we fail to connect to our backend (the catch block) or Ollama returned any HTTP error, we simply report it to the user, and end session. Replace // connect to chatterd and Ollama with:

        try {
            val response = client.newCall(request).await()
            if (!response.isSuccessful) {
                errMsg.value = parseErr(response.code.toString(),
                    apiUrl, response.body.string())
                return
            }

            // prepare placeholder
        } catch (e: Throwable) {
            errMsg.value = "llmPrompt: ${e.localizedMessage ?: "failed"}"
        }        

If the connection has been made successfully, we create a placeholder chatt for the incoming response and append it to the chatts array. The response is streamed and we want each arriving element to be displayed right away, hence the need for a placeholder chatt. Put the following code at the end of your do block, replacing // prepare placeholder:

            val resChatt = Chatt(
                username = "assistant (${chatt.username ?: "ollama"})",
                message = mutableStateOf(""),
                timestamp = Instant.now().toString()
                // import java.time.Instant, not kotlin.time.Instant
            )
            chatts.add(resChatt)

            // receive Ollama response

Finally, we receive each newline-delimited JSON (NDJSON) response and, if the line is not empty, we deserialize it into OllamaReply. The deserialization is done using Kotlin’s serialization package. Upon successful deserialization, the response property in OllamaReply is appended to the message property of our placeholder resChatt which will trigger a reactive update of the display. Put the following code at the end of your do block, replace // receive Ollama response:

            val stream = response.body.source()
            while (!stream.exhausted()) {
                val line = stream.readUtf8Line() ?: continue
                try {
                    val ollamaResponse = Json.decodeFromString<OllamaReply>(line)
                    resChatt.message?.value += ollamaResponse.response
                } catch (e: IllegalArgumentException) {
                    errMsg.value += parseErr(e.localizedMessage, apiUrl, line)
                    resChatt.message?.value += "\nllmPrompt Error: ${errMsg.value}\n\n"
                }
            }

Here’s the parseErr() helper function, put it inside your ChattStore class, outside the llmPrompt() function:

    private fun parseErr(code: String?, apiUrl: String, line: String): String {
        try {
            val errJson = Json.decodeFromString<OllamaError>(line)
            return errJson.error
        } catch (e: IllegalArgumentException) {
            return "$code\n${apiUrl}\n${line}"
        }
    }

ChattViewModel

We will have several variables accessed by multiple composables. Instead of passing these variables back and forth, we put them in a viewmodel. A composable that requires access to these variables can easily reach for the viewmodel in Android’s built-in ViewModelStore. Put the following class in your MainActivity.kt file, outside the MainActivity class: class.

class ChattViewModel(app: Application): AndroidViewModel(app) {
    val model = app.getString(R.string.model)
    val username = app.getString(R.string.model)
    val instruction = app.getString(R.string.instruction)

    val message = TextFieldState(app.getString(R.string.message))
    val errMsg = mutableStateOf("")
}

We set the username property to be the model requested of the LLM to help with the display of user prompt vs. LLM response. We declare the properties message and errMsg to be of type MutableState so that, when changed, they can trigger a reactive update of the composable(s) observing them.

Delete the composable functions Greeting() and GreetingPreview() and replace your MainActivity class definition with the following:

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 the Activity as the root 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. I found the preview of 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.”

ChattScrollView

We want to display user exchanges with Ollama in a timeline view. First we define what each row of the timeline contains. Create a new Kotlin File, ChattScrollView.kt, and put the following composable in it:

@Composable
fun ChattView(chatt: Chatt, isSender: Boolean) {
    Column(
        horizontalAlignment = if (isSender) Alignment.End else Alignment.Start,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp)
    ) {
            // chatt displayed here
    }
}

For each chatt, we check whether we’re displaying the user’s message or a response from Ollama. In the former case, we display the row flush right, else flush left. The modifier fillMaxWidth() forces the Column to use the full width of the screen.

Below we check if the message is empty. If it’s not empty, we first display the sender’s name if it is not from the user. 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 column. Add the following lines inside your Column{} block, replacing // chatt displayed here:

        chatt.message?.let { msg ->
            if (msg.value.isNotEmpty()) {
                Text(
                    text = if (isSender) "" else chatt.username ?: "",
                    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 (isSender) Chartreuse else HeavenWhite)
                        .padding(12.dp)
                        .widthIn(min = 30.dp, max = 300.dp)
                )

                Text(
                    text = chatt.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.

If your locale has a language that reads left to right, start is the same as left; for languages that read right to left (RTL), start is the same as right (conversely and similarly 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 to use with the physical world, e.g., when giving direction.

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 used to describe the UI, separate from Kotlin. Hence Compose is also 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 ChattScrollView.kt file, outside ChattView:

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

    LazyColumn(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(12.dp),
        state = listScroll,
    ) {
        items(items = chatts, key = { it.id as Any }) {
            ChattView(it, it.username == vm.username)
        }
    }
}

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

For each element in the chatts array in ChattStore, ChattView 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 chatt.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 chatts array in ChattStore is a MutableStateList. When a composable accesses the chatts array, SwiftUI automatically subscribes the composable to it so that the copmosable can be automatically recomposed chatts is modified. ChattScrollView helps ChattView determine whether a chatt belongs to the user by comparing the sender’s username against the username stored in the viewmodel obtained from Android’s built-in ViewModelStore, retrieved using the built-in composable function viewModel().

SubmitButton

While ChattView displays each chatt and ChattScrollView puts the ChattViews in a scrollable list, SubmitButton actually sends each user’s prompt to the backend and receives Ollama’s response and put both in the chatts array for ChattScrollView to display.

Create another new Kotlin File, name it MainView.kt, and put the code for SubmitButton in it:

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

    var isSending by rememberSaveable { mutableStateOf(false) }
    // `by` property delegation needs to import `setValue`
    // and `getValue` separately, so do import twice

    IconButton(
        onClick = {
            isSending = true
            vm.viewModelScope.launch(Dispatchers.Default) {
                llmPrompt(Chatt(username = vm.model,
                    message = mutableStateOf(vm.message.text.toString()),
                    timestamp = Instant.now().toString()
                    // import java.time.Instant, not kotlin.time.Instant
                ), vm.errMsg)
                // completion code
            }
        },
        // modifiers
    ) {
        // icons
    }
}
remember and rememberSaveable

As previously discusssed, to publish a subject, we make it a MutableState using mutableStateOf() or mutableStateListOf(). A composable that uses a published 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, use rememberSaveable(). States in a singleton object and in ViewModels are not destroyed across recompositions nor Activity lifecycles.

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> and 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 to the variable. T being String in this case. This is the most convenient method where it works.

  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 restructured 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.

We launch llmPrompt() using vm.viewModelScope so that our network operations survives the composable lifecycles, which got terminated when you change the orientation of your device, for example.

Upon returning from llmPrompt(), we reset vm.message and isSending. Then we scroll the display to the bottom of displayed chatts. Since scroll the display modifies the UI, we need to conduct this on the AndroidUiDispatcher.Main thread. Add the following code inside the launch {} block, replacing the comment // completion code:

                vm.message.clearText()
                isSending = false
                withContext(AndroidUiDispatcher.Main) {
                    listScroll.animateScrollToItem(chatts.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 still waiting for Ollama’s response (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: ChattViewModel = viewModel()

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

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(
                        text = stringResource(R.string.chatter),
                        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 pass to ChattScrollView to control the scrolling of the LazyColumn it instantiate, which 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 Chatter.

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 any where 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. Let’s show the chatt timeline 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),
        ) {
            ChattScrollView(modifier = Modifier.weight(1f), listScroll)
            
            // prompt input and submit
        }

The modifier imePadding() causes the Column to not extend behind the soft keyboard when it is shown. 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 ChattScrollView at the top of its column.

Below ChattScrollView, we now put a text box, where user can enter their Ollama 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(verticalAlignment = Alignment.Bottom,
                modifier = Modifier
                    .padding(top = 12.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(horizontal = 10.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 TextField(), 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 chatts array.

Before we leave MainView, we check whether the value in vm.errMsg. If it’s not empty, we show an alert dialog with the error message in vm.errMsg. 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 = ""
                    },
                    confirmButton = {
                        TextButton(onClick = {
                            vm.errMsg.value = ""
                        }) {
                            Text(
                                "OK",
                                fontSize = 28.sp,
                                fontWeight = FontWeight.Bold,
                            )
                        }
                    },
                    title = {
                        Text(
                            "LLM Error",
                            fontWeight = FontWeight.Bold
                        )
                    },
                    text = {
                        Text(
                            vm.errMsg.value,
                            fontSize = 20.sp,
                            lineHeight = 24.sp,
                            letterSpacing = 0.5.sp
                        )
                    }
                )
            }

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 backend!)

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. Change serverUrl in ChattStore from YOUR_SERVER_IP to 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 chatterd.crt to YOUR*TUTORIALS on your laptop. Enter the following commands:

laptop$ cd YOUR*TUTORIALS
laptop$ scp -i reactive.pem ubuntu@YOUR_SERVER_IP:reactive/chatterd.crt chatterd.crt

Install chatterd.crt onto your Android: download chatterd.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/llmprompt. Since /llmprompt does not have a GET method, the browser may say, Cannot GET /llmprompt. As long as you’re not getting a security-related error message, it indicates that your self-signed certificate is installed correctly.

Preparing Chatter

Next, we need to tell Chatter 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 do not use the <base-config> tag because it 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.composeChatter">

Finally, change the serverUrl property of your ChattStore class from mada.eecs.umich.edu to YOUR_SERVER_IP. Build and run your app and you should now be able to connect your mobile front end to your back end via HTTPS. Your frontend must work with both mada.eecs.umich.edu and your backend. :point_right:You will not get full credit if your submitted front end is not set up to work with your backend!

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 llmprompt. 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 further 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, only files needed for grading will be pushed to GitHub.

  reactive
    |-- chatterd            
    |-- chatterd.crt 
    |-- llmprompt
        |-- composeChatter
            |-- 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 your team mate’s name and uniqname in your repo’s README.md (click the pencil icon at the upper right corner of the README.md box on your git repo) so that we’d know. 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 mate’s) 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

NDJSON

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: August 27th, 2025