Tutorial: Chatter Compose
Cover Page
DUE Wed, 09/10, 2 pm
Preparing your GitHub repo
- On your laptop, navigate to
YOUR*TUTORIALS*/
- Create a zip of your
llmprompt
folder - Rename your
llmprompt
folder**chatter**
- Remove your chatter’s
.gradle
directory by running in a shell window:laptop$ cd YOUR*TUTORIALS/chatter/composeChatter laptop$ rm -rf .gradle
- Push your local
YOUR*TUTORIALS*/
repo to GitHub and make sure there’re no git issues:<summary>git push</summary>
- Open GitHub Desktop and click on
Current Repository
on the top left of the interface - Click on your assignment GitHub repo
- Add Summary to your changes and click
Commit to main
- If you have pushed other changes to your Git repo, click
Pull Origin
to synch up the clone on your laptop - Finally click on
Push Origin
to push changes to GitHub
- Open GitHub Desktop and click on
Go to the GitHub website to confirm that your folders follow this structure outline:
reactive
|-- chatter
|-- composeChatter
|-- app
|-- gradle
|-- chatterd
|-- chatterd.crt
|-- llmprompt.zip
If the folders in your GitHub repo does not have the above structure, we will not be able to grade your assignment and you will get a ZERO.
Gradle build setup
Open YOUR*TUTORIALS/chatter/composeChatter
in Android Studio.
Edit the /Gradle Scripts/build.gradle.kts (Module:app)
file.
Scroll down to the dependencies
block near the bottom of the file and
add the following lines inside the block, below the other implementation
statements:
implementation("org.jetbrains.kotlin:kotlin-reflect:2.2.10")
Kotlin reflect
helps with code introspection, as we will use in ChattStore
below.
As in the previous tutorial, you can accept (or not) Android Studio’s the recommendation to convert the above dependency to the “new library catalog definitions”, after which
you should re-sync your Gradle files.
ChattStore
We now tackle networking for the app, but first add the following properties near the top of
your ChattStore
object, right below the chatts
array declaration:
private val nFields = Chatt::class.declaredMemberProperties.size
private val mutex = Mutex()
private var isRetrieving = false
Chatt::class.declaredMemberProperties.size
uses reflection to look up the number of properties in the Chatt
type. We store the result in the variable nFields
for later validation use.
Now add the postChatt()
method inside the ChattStore
singleton object
:
suspend fun postChatt(chatt: Chatt, errMsg: MutableState<String>) {
val jsonObj = mapOf(
"username" to chatt.username,
"message" to chatt.message?.value,
)
val requestBody = JSONObject(jsonObj).toString()
.toRequestBody("application/json".toMediaType())
val apiUrl = "${serverUrl}/postchatt"
val request = Request.Builder()
.url(apiUrl)
.header("Content-Type", "application/json")
.post(requestBody)
.build()
try {
val response = client.newCall(request).await()
if (!response.isSuccessful) {
errMsg.value = "postChatts: ${response.code}\n$apiUrl"
}
response.body.close()
} catch (e: IOException) {
errMsg.value = "postChatt: ${e.localizedMessage ?: "POSTing failed"}"
}
}
We post a chatt
to chatterd
similar to how we posted an Ollama prompt in the
previous tutorial. Unlike the llmprompt
API, however, the postchatt
API is not expected to return anything on successful post.
Next add the getChatts()
method to the class:
suspend fun getChatts(errMsg: MutableState<String>) {
// only one outstanding retrieval
mutex.withLock {
if (isRetrieving) { return }
isRetrieving = true
}
// do the get
mutex.withLock {
isRetrieving = false
}
}
To prevent multiple outstanding getChatts()
, we let getChatts()
proceed only if there is no outstanding getChatts()
presently retrieving
from the backend. To ensure thread safety, we allow only mutually exclusive
access to the isRetrieving
variable. If there were no other outstanding
getChatts()
running, we proceed and then, before leaving the function,
we signal to other future getChatts()
that we’re done with this retrieval
so that they can perform their own retrieval. Replace // do the get
comment with:
val apiUrl = "${serverUrl}/getchatts"
val request = Request.Builder()
.url(apiUrl)
.header("Accept", "application/json")
.build()
try {
val response = client.newCall(request).await()
if (!response.isSuccessful) {
errMsg.value = "getChatts: ${response.code}\n$apiUrl"
response.body.close()
return
}
// unpack the data
} catch (e: IOException) {
errMsg.value = "getChatts: ${e.localizedMessage ?: "Failed GET request"}"
}
The above is again similar to how we received a response from the backend
in the llmprompt()
, though now we unpack the received data differently.
Replace // unpack the data
line with the following:
val chattsReceived = try {
JSONArray(response.body.string())
} catch (e: JSONException) {
JSONArray() // assign it an empty JSONArray
}
chatts.clear()
for (i in 0 until chattsReceived.length()) {
val chattEntry = chattsReceived[i] as JSONArray
if (chattEntry.length() == nFields) {
chatts.add(
Chatt(
username = chattEntry[0].toString(),
message = mutableStateOf(chattEntry[1].toString()),
id = UUID.fromString(chattEntry[2].toString()),
timestamp = chattEntry[3].toString(),
)
)
} else {
errMsg.value =
"getChatts: Received unexpected number of fields ${chattEntry.length()} instead of ${nFields}"
}
}
The server will return the chatt
s as a JSON Array. JSON (de)serialization
libraries such as Kotlin Serialization, which we used in the previous tutorial,
work well with JSON objects (“key”: “value” pair), but requires manually
programming a recursive descent parser to deserialize an array of unkeyed
values, as used in the present tutorial, which turns out to be more complicated
than the manual deserialization we have done here.
The UI
Now we update the app’s UI.
Strings
Edit the file /app/res/values/strings.xml
and update the app’s name and username
to your uniqname (replace DUMMY_UNIQNAME
with your uniqname):
<string name="chatter">Chatter</string>
<string name="username">DUMMY_UNIQNAME</string>
ChattViewModel
In MainActivity.kt
file, update the username
property of your ChattViewModel
class by replacing the initialization with the actual username:
val username = app.getString(R.string.username)
Previously we set the username
property to model
since we identified
the user with the model requested of the LLM.
SubmitButton
In MainView.kt
, we update our SubmitButton
: search for the call to llmPrompt()
and replace it with a call to postChat()
, which incidentally, does not need
to have the timestamp
field of Chatt
to be filled in:
postChatt(Chatt(vm.username,
mutableStateOf(vm.message.text.toString())
), vm.errMsg)
if (vm.errMsg.value.isEmpty()) { getChatts(vm.errMsg) }
After the user successfully posted a chatt
, we automatically call getChatts()
to refresh our chatts
array (and have it displayed, with the display positioned at the last posted chatt
).
MainView
To support human-to-human chat, we add two features to MainView()
:
-
when the app starts, we automatically retrieve all existing
chatt
s and display them on the timeline, scrolled to the last postedchatt
, and -
since our backend doesn’t push
chatt
s posted by others, we allow user to performpull-to-refresh
to run anothergetChatts()
.
First add the following lines to your MainView()
composable, right
before the call to Scaffold
:
val scope = rememberCoroutineScope()
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
vm.viewModelScope.launch {
getChatts(vm.errMsg)
withContext(AndroidUiDispatcher.Main) {
listScroll.animateScrollToItem(chatts.size)
}
}
}
The automatic retrieval of chatt
s from the backend and scrolling to the
last posted chatt
is performed within LaunchedEffect()
as a side effect
to be evaluated only on first launch (Unit
key). We launch getChatts()
in viewModelScope
so that it will survive device orientation change,
but animateScrollToItem()
must be run back in the main thread since it
updates the UI. The variable isRefreshing
we use in performing pull-to-refresh below.
Make all of Scaffold()
’s content
parameter, presented as its trailing
lambda, argument to the content
parameter of PullToRefreshBox()
and make
PullToRefreshBox()
the argument to Scaffold()
’s content parameter. Hence
from what we currently have:
Scaffold(
// . . . other parameters
) {
// content parameter, comprising a call to Column( ){ }
}
replace the call to Column() {}
with a call to PullToRefreshBox(){ Column(){} }
:
Scaffold(
// . . . other parameters
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize()
.zIndex(1f),
isRefreshing = isRefreshing, // hide or show loading icon
onRefresh = {
isRefreshing = true // show loading icon
scope.launch {
getChatts(vm.errMsg)
withContext(AndroidUiDispatcher.Main) {
listScroll.animateScrollToItem(chatts.size)
}
isRefreshing = false // hide loading icon
}
},
) {
// original Scaffold content parameter, comprising a call to Column( ){ }
}
}
This pull-to-refresh library works only if your swipe gesture starts from inside a list. If your list is empty, it is not shown on screen and therefore you cannot initiate pull to refresh. You also cannot initiate pull to refresh from the empty space below a list. When you can initiate it, pull to refresh shows a “loading” icon and run the onRefresh
lambda expression.
Note that pull-to-refresh doesn’t actually refresh the view, it calls getChatts()
which refreshes the chatts
array. Compose then recomposes the view given the updated chatts
array, with the display positioned at the last posted chatt
. In this way, pull-to-refresh refreshes our chatts
array with postings from other users.
LaunchedEffect vs. rememberCoroutineScope
To launch a suspending side-effect function from a composable, we can launch it from inside a LaunchedEffect { }
block. However, LaunchedEffect { }
itself is a composable and thus can only be called from inside a composable. It cannot be called, for example, from the onClick {}
block of a Button
. Instead, we can use rememberCoroutineScope()
to obtain the composable’s coroutine scope that we can then use to launch suspending side-effect function from such non-composable spots inside the composable. The advantage of using the composable’s coroutine scope, instead of MainScope()
, for example, is that we get structured concurrency: the launched coroutine will be automatically cancelled when the composable is dismissed, so we’re not leaking dangling coroutines.
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
, by changing the serverUrl
property in your ChattStore
to mada.eecs.umich.edu
. Once you have your backend setup, change serverUrl
back to YOUR_SERVER_IP
. You will not get full credit if your 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 use 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:
- Open GitHub Desktop and click on
Current Repository
on the top left of the interface - Click on the GitHub repo you created at the start of this tutorial
- Add Summary to your changes and click or
Commit to main
at the bottom of the left pane - If you have pushed code to your repo, click
Pull Origin
to synch up the repo on your laptop - Finally click
Push Origin
to push all changes to GitHub
Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo under the folder
chatter
. 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 Tutorials, only files needed for grading will be pushed to GitHub.
reactive
|-- chatter
|-- composeChatter
|-- app
|-- gradle
|-- chatterd
|-- chatterd.crt
|-- llmprompt.zip
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.
Review your information on the Tutorial and Project Links sheet. If you’ve changed your teaming arrangement from previous lab’s, please update your entry. If you’re using a different GitHub repo from previous lab’s, invite eecsreactive@umich.edu
to your new GitHub repo and update your entry.
References
- Synchronized
- Material3 PullToRefresh for Jetpack Compose
- Basic alert dialog
- suspendCancellableCoroutine
- Mutual exclusion
Array and JSON
- Java convert a Json string to an array
- Convert normal Java Array or ArrayList to Json Array in android
- How to initialize list in Kotlin
- Difference between List and Array types in Kotlin
Side-effects
- Jetpack Compose Effect Handlers
- Side-effects in Compose
- Handling the system back button
- LauchedEffect vs. rememberCoroutineScope in Jetpack Compose
Appendix: imports
Prepared by Alex Wu, Tiberiu Vilcu, Nowrin Mohamed, Chenglin Li, Xin Jie ‘Joyce’ Liu, and Sugih Jamin | Last updated: August 25th, 2025 |