Chatter Compose

Cover Page

DUE Wed, 09/18, 2 pm

In this lab you’ll learn how to retrieve textual chatts from a back-end server and how to post to it. You will familiarize yourselves with the Android app development environment and the basics of the platform. You’ll learn some Kotlin syntax and language features that may be new to you. And you will use Android Jetpack Compose to build UI declaratively. Let’s get started!

Expected behavior

Posting a new chatt:

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 release mode. It could be several seconds after launch for the icons and messages shown on the video to show up.

Preliminaries

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

Before we start, you’ll need to prepare a GitHub repo for you to submit your labs and for us to communicate your lab grades back to you. Please follow the instructions in Preparing GitHub for EECS 441 Labs and then return here to continue.

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 lab 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: specify the full path where your composeChatter folder is to be located, which will be 👉👉👉 YOUR*LABS*FOLDER/chatter/composeChatter/, where YOUR*LABS*FOLDER is the name you give to your 441 GitHub repo clone folder above.
  6. Minimum SDK: API 33 (“Tiramisu”; Android 13.0)

    our labs are backward compatible to API 33 only.

  7. Build configuration language: Kotlin DSL (build.gradle.kts)
  8. Click Finish

Subsequently in this and other labs, 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:

  441
    |-- chatter   
        |-- composeChatter
            |-- app
            |-- gradle

If the folders in your GitHub repo does not have the above structure, we will not be able to grade your labs 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

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, all the layouts suggested in this and all subsequent labs are suggestions. 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, your project should then be structured per the above.

While not an industry convention, in this course we name the initial View MainView.

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.0.0"
# . . .
compose-bom = "2024.09.03"
# . . .

“BoM” stands for “Bill of Materials.” It lists the versions of libraries compatible with each other.

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

Scroll down until you see the dependencies block near the bottom of the file and <!– update "androidx.compose:compose-bom" to:

    implementation(platform("androidx.compose:compose-bom:2023.10.01"))

“BoM” stands for “Bill of Materials.” It lists the versions of libraries compatible with each other. Do not use the “2023.08.00” or later BoM unless you want to build for API Level 34 (Android 14).

Then –> add the following lines inside the block, below the other implementation statements:

    implementation("androidx.compose.material3:material3:1.3.0")
    implementation("androidx.navigation:navigation-compose:2.8.0")
    implementation("com.android.volley:volley:1.2.1")
    implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0")

Navigation compose helps us navigate between views, Kotlin reflect helps with code introspection, as we will explain in ChattStore below, and Volley is a native android library used to make HTTP requests.

compileSDK, minSDK, targetSDK

In the android block of your app build file, set compileSDK to the latest Android API level, e.g., 34. 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 = 34 (Android 14), but minSDK = 31 (Android 11), 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 to between minSDK and compileSDK, usually it will be set to compileSDK and Google PlayStore has a minimum supported version (API Level 33 at the time of writing). 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.material.material:1.6.8)”, 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.androidx.material)

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

# . . .
material = "1.6.8"

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

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

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.

Chatter

The Chatter app consists of two views: one to write and post a chatt to server, and the other, the main view, to show retrieved chatts. It is cheaply inspired by Twitter. And it already has a live web API:

https://mada.eecs.umich.edu/getchatts/
https://mada.eecs.umich.edu/postchatt/

You will create your own back end later in the lab.

First let’s define some strings we will be using in the app: open the file /app/res/values/strings.xml. Inside the resources block, below the line listing your app_name, add:

    <string name="chatter">Chatter</string>
    <string name="post">Post</string>
    <string name="send">Send</string>
    <!--                    👇👇👇👇👇👇👇👇👇 -->
    <string name="username"><YOUR UNIQNAME></string>
    <string name="message">Some short sample text.</string>
    <string name="back">Back</string>    

Replace <YOUR UNIQNAME> with your uniqname, remove the angle brackets,< >

Let’s also define some additional colors we will be using: open the file /app/java/YOUR_PACKAGENAME.ui.theme/Color.kt and add the following colors–some of these we will use in latter labs:

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

val Canary = Color(0xFFFFC107)
val Moss = Color(0xFF526822)
val Firebrick = Color(0xFFB22222)
val DarkGreen = Color(0xFF006400)

Permission, navigation, and dependency

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

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

Chatt

To post a chatt with the postchatt API, the Chatter back end server expects a JSON object consisting of “username” and “message”. For example:

{	
   "username": <YOUR UNIQNAME>,	
   "message": "Hello world!"	
}

Chatter’s getchatts API will send back all accumulated chatts in the form of a JSON array of string arrays. Each string array consists of four elements: “username”, “message”, “id”, and “timestamp”. For example:

[
    ["username0", "message0", "id0", "timestamp0"],
    ["username1", "message1", "id1", "timestamp1"], 
    ... 
]

Each element of the string array may have a value of JSON null or the empty string ("").

Create a new Kotlin File (not Class):

  1. Right click on/app/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 labs.

  4. Place the following class definition for Chatt in the newly created file:

     class Chatt(var username: String? = null, 
                 var message: String? = null, 
                 var id: UUID? = null,
                 var timestamp: String? = null,
                 var altRow: Boolean = true)
    

    We use randomly generated UUID to identify each chatt. Since multiple front-end apps can post chatts to the same back end, the UUID generation will be done by the back end as it enters each chatt into its database. The altRow property is used to alternate the background color of the entries when displayed in a list.

ChattStore as Model

We will declare a ChattStore object to hold our array of chatts. Since the chatts are retrieved from the Chatter back-end server and sent to the same back-end server when the user posted a chatt, we will keep the network functions to communicate with the server as methods of this class.

Create another Kotlin File (not Class), call it ChattStore, and place the following ChattStore object in it:

object ChattStore {
    var chatts by mutableStateOf(emptyList<Chatt>())
        private set
    private val nFields = Chatt::class.declaredMemberProperties.size - 1

    private var isRetrieving = false

    private lateinit var queue: RequestQueue
    private const val serverUrl = "https://mada.eecs.umich.edu/"

    fun initQueue(context: Context) {
        queue = newRequestQueue(context)
    }    
}

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

With auto import enabled, as you enter code, Android Studio will automatically detect and determine which library you need to import. It will then prompt you to hit the Alt-Enter or Opt-Enter key combination to automatically import said library. Go ahead and import the suggested libraries.

Sometimes, there are multiple library choices and Android Studio would have you choose. Most of the time the choice will be rather obvious (don’t choose the Audio library if you’re not implementing Audio, or do 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 lab 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 do so, 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 therefore unnecessary.

Using the keyword object to declare the class ChattStore makes it a singleton object, meaning that there will ever only be one instance of this class when the app runs.

The chatts array will be used to hold the chatts retrieved from the back-end server. While we want chatts to be readable outside the class, we don’t want it modifiable outside the class, and so have declared a private _chatts and made available an immutable version as chatts. The array _chatts is declared as 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 code Chatt::class.declaredMemberProperties.size uses introspection to look up the number of properties in the Chatt type. We store the result (-1 to discount the altRow property used only on the front end) in the variable nFields for later validation use.

We will call the initQueue() function to initialize the Volley’s HTTP request queue later.

Context

The Context, the computing resources and environment, off which the request queue resides thus must persist for the whole lifetime of the app. Each Android Activity has its own Context. The whole application also has its own Context. The activity context contains states such as the theme applied to the activity and lasts only as long as the lifetime of its activity. The application context holds lower-level system access and stays around for the whole lifetime of the application. As we will see later in the MainActivity section, we pass the applicationContext from MainActivity when calling initQueue() so that we can use it in creating our Volley request queue. It is a common Android development pattern to pass Context around as most system APIs require access to Context.

ChattListRow

We want to display the chatts retrieved from the backend in a timeline view. First we define what each row contains. Create a new Kotlin File, ChattListRow, and place the following composable in it:

@Composable
fun ChattListRow(chatt: Chatt) {
    Column(modifier = Modifier.padding(8.dp, 0.dp, 8.dp, 0.dp)
        .background(color = if (chatt.altRow) Gray88 else HeavenWhite)) {
        Row(horizontalArrangement = Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth(1f)) {
            chatt.username?.let { Text(it, fontSize = 17.sp, modifier = Modifier.padding(4.dp, 8.dp, 4.dp, 0.dp)) }

            chatt.timestamp?.let { Text(it, fontSize = 14.sp, textAlign = TextAlign.End, modifier = Modifier.padding(4.dp, 8.dp, 4.dp, 0.dp)) }
        }
        
        chatt.message?.let { Text(it, fontSize = 17.sp, modifier = Modifier.padding(4.dp, 10.dp, 4.dp, 10.dp)) }
    }
}
"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, previously dip) 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.

The content of a ChattListRow composable consists of a Column of two items: a Row on top and, below it, a text box containing the chatt message. The chatt message is padded so that the message is displayed inside the textbox, away from the edges of the textbox.

The upper Row consists of two items: a text box containing the username and another text box containing the timestamp. The contents of these boxes are also padded except for their bottom edges. These two boxes are spaced out evenly with no extra margins at the start and end of the row (which is what Arrangement.SpaceBetween mean), with the final result being that the two textboxes are flushed left and right respectively. By default, all textboxes take up as much space as needed by their contents.

Margins vs. padding

Difference between margins and padding: when specifying constraints between UI elements, we set the margin between them. Within a UI element, its content, such as text, is displayed at a certain offset (padding) from the sides of the element. The perimeter of a UI element is its border, whose thickness can also be changed. Here are a number of illustrations to help explain:

margins, border, padding
Source: Margin vs. Padding

padding, margins example
Source: stackoverflow article

The thing to remember is that the margins of a View or a Layout are outside the View/Layout, the margins specify the relationship of a UI element to its parent’s boundaries or to other (“sibling”) Views/Layouts within the parent ViewGroup. Whereas padding applies inside a UI element, between the border of the UI element and its contents.

If your locale has a language that reads left to right, Start is the same as Left, otherwise 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 two ends of a UI element, reserving left and right to 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” here being UI description.

MainView

Create another new Kotlin File, name it MainView, and add the following initial declaration of MainView:

@Composable
fun MainView() {
    Scaffold(
        topBar = {
            TopAppBar(title = {
                Text(
                    text = stringResource(R.string.chatter),
                    fontSize = 20.sp
                )
            })
        },
        floatingActionButton = {
            FloatingActionButton(
                containerColor = Canary,
                contentColor = Moss,
                shape = CircleShape,
                modifier = Modifier.padding(0.dp, 0.dp, 10.dp, 10.dp).scale(1.2f).zIndex(1f),
                onClick = {
                    // navigate to PostView
                }
            ) {
                Icon(Icons.Default.Add, stringResource(R.string.post), modifier = Modifier.scale(1.3f))
            }
        }
    ) {
        // content of Scaffold
            // describe the View
    }
}

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

  1. a TopAppBar that consists of only a textbox containing the string Chatter and
  2. a FloatingActionButton with a moss-colored ‘+’ icon on a canary-colored background, offset 10dp from the trailing/end and bottom edges of the screen. We leave the onClick action of the floating action button empty for now.

Both the topBar and floatingActionButton parameters of Scaffold take as argument a composable with zero parameter and Unit return value. Since a function is not a reference to the function in Kotlin, to use the TopAppBar() and FloatingActionButton() composables we thus need to wrap each in a parameterless lambda returning Unit value, as we have done above.

When adding the TopAppBar composable, Android Studio will put a red squiggly line under it. Hovering over the line, you’ll see that Android Studio prompts you to Opt in for 'ExperimentalMaterial3Api on MainView'. Go ahead and click on the recommendation to accept it.

You can experiment with other colors by consulting the Material Design 3 Color System.

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. Put the following code below the comment, //describe the View:

        // content of Scaffold
            // describe the View
            val listState = rememberLazyListState()
            LaunchedEffect(chatts[0].id) {
               listState.animateScrollToItem(0)
            }

            LazyColumn(
                verticalArrangement = Arrangement.SpaceAround,
                modifier = Modifier.padding(
                        it.calculateStartPadding(LayoutDirection.Ltr),
                        it.calculateTopPadding() + 10.dp,
                        it.calculateEndPadding(LayoutDirection.Ltr),
                        it.calculateBottomPadding()
                    )
                    .background(color = WhiteSmoke),
                state = listState,                    
            ) {
                items(items = chatts, key = { it.id as Any }) {
                    ChattListRow(it)
                }
            }

If AndroidStudio complains about the syntax of items() above, manually add import androidx.compose.foundation.lazy.items to the top of the file.

A LazyColumn is a Column that only allocates space and renders the view of currently visible data, ideal for large data set. The function items() apply the provided composable, ChattListRow, to the items, but it only recomposes an item if the key of that item has changed—in this case, if a row’s Chatt.id has not changed, the row will not be recomposed even if it gets moved around the array. We also specify that LazyColumn should space the items evenly with the margin of the top and bottom edges being half the spacing between items (Arrangement.SpaceAround).

By default, each row of a lazy list, such as LazyColumn, is identified by its position. When we add items to the chatts array and push the current contents down, the lazy list would have to recompose all existing rows since they are no longer at their old positions. We could instead identify each row by its id, which is unique per chatt, so that Compose would not recompose rows that have been moved but otherwise their contents have not changed. As a consequence of having keyed items, however, the scroll position of a LazyColumn is maintained based on the key and since we prepend new items to the chatts array, the new items at the top of the list would not be shown. The variable listState is used to get “automatic scrolling to the top of the list upon recomposition” back. We call LazyListState.animateScrollToItem(0) when the id of the first item in the chatts array changes.

getChatts()

If you run your app now, you will get a blank screen as we have no means to retrieve and populate the chatt timeline yet.

Let us return to ChattStore and add the following getChatts() method to the class, to retrieve chatts from the back-end server:

    fun getChatts() {
        // only one outstanding retrieval
        synchronized (this) {
            if (isRetrieving) {
                return
            }
            isRetrieving = true
        }

        val getRequest = JsonArrayRequest("${serverUrl}getchatts/",
            { response ->
                var idx = 0

                var _chatts = mutableListOf<Chatt>()
                for (i in 0 until response.length()) {
                    val chattEntry = response[i] as JSONArray
                    if (chattEntry.length() == nFields) {
                        _chatts.add(Chatt(username = chattEntry[0].toString(),
                            message = chattEntry[1].toString(),
                            id = UUID.fromString(chattEntry[2].toString()),
                            timestamp = chattEntry[3].toString(),
                            altRow = idx % 2 == 0))
                        idx += 1
                    } else {
                        Log.e("getChatts", "Received unexpected number of fields: " + chattEntry.length().toString() + " instead of " + nFields.toString())
                    }
                }
                chatts = _chatts
                synchronized(this) {
                    isRetrieving = false
                }                
            },
            { e -> 
                synchronized(this) {
                    isRetrieving = false
                }          
                Log.e("getChatts", e.localizedMessage ?: "NETWORKING ERROR")
            }
        )

        queue.add(getRequest)
    }

We first check whether the app is already in the middle of retrieving chatts from the back end. If not, we can proceed. Checking and updating isRetrieving is serialized using JVM monitor based synchronized() function.

To retrieve chatts, we create a JsonObjectRequest() with the appropriate GET URL. The server will return the chatts as a JSON array. In the completion handler to be invoked when the response returns, we decode the serialized JSON value from the returned response. After the chatts array has been fully updated, we update the isRetrieving property to allow subsequent retrieval.

Once the GET request is created, we submit it to the request queue managed by the Volley networking library for asynchronous execution.

Moshi and Kotlin Serialization
Swift Codable

To (de)serialize between JSON and Kotlin data, we could use Moshi or Kotlin Serialization. Both work well with JSON objects (“key”: “value” pair), but requires manual parsing for array of unkeyed values, used in our labs. Programming Moshi’s or Kotlin Serialization’s recursive descent parser declaratively to deserialize unkeyed data, potentially mixed with further nested unkeyed data (i.e., array of String or List<String> as in the Maps lab) is more complicated than deserializing the JSON data directly, imperatively. Use of property delegation in subsequent labs require further programming of both parsers, if at all possible.

Instead of encoding/decoding between JSON and Kotlin data by hand, as we have done here, we could use Kotlin serialization. This would, however, require every field of each entry to be a key-value pair, as opposed to an array of values with no key as we have done. Given the small number of entries in Chatt, the current approach seems to work well enough.

Pull to refresh

Now that we have a means to retrieve chatts from the back-end server, we return to MainView to retrieve the chatts and use them to populate the LazyColumn composable. Add the following local variables to the MainView() composable:

    var isRefreshing by remember { mutableStateOf(false) }

Wrap the existing call to LazyColumn() inside a PullToRefreshBox() composable such that the call to PullToRefreshBox() becomes the full content of Scaffold()’s trailing lambda:

        // content of Scaffold
        PullToRefreshBox(
            modifier = Modifier.fillMaxSize().zIndex(1f),
            isRefreshing = isRefreshing,    // hide or show loading icon
            onRefresh = {
                isRefreshing = true         // show loading icon
                getChatts()
                Timer().schedule(250) {     // let isRefreshing = true "settle"
                    isRefreshing = false    // hide loading icon
                }
            },            
        ) {
            // describe the View
            // ... keep code for listState ...
            LazyColumn(/* ... no change, keep code ... */) {
                // ... keep current code ...
            }
        }

You can pull to refresh 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 in this case doesn’t actually refresh the view, it calls getChatts() which refreshes the chatts array. Compose then recomposes the view given the updated chatts array. When the user posts a chatt, we will automatically call getChatts() to refresh our chatts array. Pull to refresh refreshes our chatts array with postings from other users. Since our backend doesn’t implement a push mechanism, such as websockets, the user must actively pull to refresh.

PostView

We are not done with MainView, but let us put it aside for awhile and shift our focus to PostView, which we will use to compose and post a chatt. Create a new Kotlin File, call it PostView and again create a composable with Scaffold():

@Composable
fun PostView() {
    val context = LocalContext.current

    val username = stringResource(R.string.username)
    var message by rememberSaveable { mutableStateOf(context.getString(R.string.message)) }

    Scaffold(
        // put the topBar here
    ) {
        Column(
            verticalArrangement = Arrangement.SpaceAround,
            modifier = Modifier.padding(
                it.calculateStartPadding(LayoutDirection.Ltr)+8.dp,
                it.calculateTopPadding(),
                it.calculateEndPadding(LayoutDirection.Ltr)+8.dp, 
                it.calculateBottomPadding())
        ) {
            Text(username,
                Modifier
                    .padding(0.dp, 30.dp, 0.dp, 0.dp)
                    .fillMaxWidth(1f), textAlign=TextAlign.Center, fontSize = 20.sp
            )

            TextField(
                value = message,
                onValueChange = { message = it },
                modifier = Modifier
                    .padding(8.dp, 20.dp, 8.dp, 0.dp)
                    .fillMaxWidth(.8f),
                textStyle = TextStyle(fontSize = 17.sp),
                colors = TextFieldDefaults.colors(unfocusedContainerColor = Color.Transparent,
                    focusedContainerColor = Color.Transparent)
            )
        }
    }
}

We will need to access Android’s Activity context to retrieve the default message string. In Compose, data hoisted and made available to a composable sub-tree is called a CompositionLocal variable. A CompositionLocal variable is an observable state that can be subscribed to by composables within the sub-tree where the variable is provided. Since passing Activity context is such a common operation in building Android apps, it is provided to all composables as the CompositionLocal variable LocalContext. While CompositionLocal variables are globally visibile, their states or values are scoped to the sub-tree where the data is provided. The advantage of using a CompositionLocal variable is that we don’t have to pass/drill it down a sub-tree, yet composables in the sub-tree can subscribe and react to changes in the variable. We read the current Activity context off LocalContext.current and save it in the context variable.

We describe PostView as a Column of two UI elements (as composables): a text box displaying the immutable variable username and an editable TextField. The value parameter of TextField is subscribed to message, which we have declared a MutableState.

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 are not destroyed across recompositions nor activity lifecycles. States declared in an Activity can be maintained across lifecycles by declaring them inside a ViewModel(), as we will see later in the course.

We use rememberSaveable { } here to retain the value of message if the user navigates to another view and back, prior to sending the chatt. As explained in a stackoverflow answer (second comment), “If you navigate to a different composable, the previous composable is destroyed - along with the state kept by remember. If you want to retain that state, rememberSaveable will preserve it when you navigate back. When you are navigating to and from screens, you are not recomposing but composing from a fresh start.” However, rememberSaveable { } preserves state only if its composable is still on the navigation stack. Once its composable is popped off the stack, none of its states will be further retained.

Next we provide a lambda to the onValueChange parameter to update the value of message whenever user enters new input. When message updates, value will be notified and it will react by recomposing TextField, thereby updating the text displayed (otherwise, text entered won’t show up in the edit box).

Three ways to remember

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>
    

    however the variable will then be of type MutableState<T>.

  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.

  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 TextField(), its value parameter will be assigned the getter, message, and its onValueChange parameter assigned the setter, setMessage.

We use a NavController to move from composable to composable. In order that all composables under navigation have access to this NavController, we declare (“lift”) it as high up in our composable hierarchy as necessary, to encompass all composables under navigation as a subtree of that point (“state hoisting”). For us, since we want to navigate between both of our composables (MainView and PostView), we declare the NavController at the root of the view hierarchy, i.e., in MainActivity.

In your MainActivity.kt file, but outside the MainActivity class definition, create a global CompositionLocal key:

val LocalNavHostController = staticCompositionLocalOf<NavHostController> { error("LocalNavHostController provides no current")}

We will initialize the variable later. If it is accessed before any value has been provided, we call error() to throw an IllegalStateException. We use staticCompositionLocalOf() because the navController never changes once initialized.

MainActivity

Now put the following code inside the MainActivity class, after the call to super.onCreate(savedInstanceState). Replace the template setContent {}, and its call to Greeting(), with the following code (you can also delete the now superfluous Greeting composable that is part of the provided template):

        initQueue(applicationContext)
        getChatts()
        enableEdgeToEdge()
        setContent {
            val navController = rememberNavController()
            CompositionLocalProvider(LocalNavHostController provides navController) {
                NavHost(navController, startDestination = "MainView") {
                    composable("MainView") {
                        MainView()
                    }
                    composable("PostView") {
                        PostView()
                    }
                }
            }
        }

After initializing the Volley queue, we call getChatts() to retrieve chatts from the back end on application launch.

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.

In setContent, we create a navigation controller navController using rememberNavController() so that it survives recompositions of MainActivity’s root view. Then we wrap the whole UI tree below the root inside a CompositionLocalProvider() that provides the navController to the CompositionLocal variable LocalNavHostController. All the composables in the whole UI tree below the root can now access this navController.

Associated with each NavController is a NavHost, which ties the NavController to the collection of composables you want to put under navigation. Collect all the composables you want to put under navigation. Give each a name/identification in the form of a text string. This is known as the path to each composable (though the navigation “graph” is only one level deep) and will be used by Navigation to route to your composable. In creating the NavHost, we specify that navigation should start at the composable with name or path “MainView”. Then we provide NavHost with the two composables we want under navigation, MainView and PostView. For each composable(), we first specify its path, as a String, and provide a lambda that calls the composable associated with the path.

Once we have navigation setup, we can move between composables. Let’s return to MainView and put in the code to navigate to PostView when the floating action button is clicked. First add a local variable to the MainView() composable to save the current LocalNavHostController off CompositionLocal:

    val navController = LocalNavHostController.current

Then add the following line under the // navigate to PostView comment inside the lambda assigned to the onClick parameter of the call to FloatingActionButton:

                    // navigate to PostView
                    navController.navigate("PostView")
@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 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.

Posting chatts

As we have done in MainView, first add a local variable to the PostView() composable to save the current LocalNavHostController off CompositionLocal:

    val navController = LocalNavHostController.current

In the following code, we give PostView() a title, a button at the upper right corner to post a chatt, and a “back arrow” navigation button at the upper left corner, in case we want to cancel posting and return to MainView. Put the following code in PostView under the comment, // put the topBar here in the call to Scaffold():

        // put the topBar here
        topBar = {
            TopAppBar(title = { Text(text = stringResource(R.string.post),
                fontSize=20.sp) },
                modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp),
                navigationIcon = { ArrowBack() },
                actions = { SubmitButton() }
            ) },

In the call to create the TopAppBar, we give it a title, with font size 20 sp. The title is stored as a “string” resource named “post” in the /app/res/strings.xml file. Then we add a navigationIcon button, which is placed to the left of the title, following the default Material Design layout. Finally, we add an actions button, which, by default, is placed flushed right on the top bar.

We put the definition of both navigationIcon and actions buttons in separate composables. Add the following code inside your PostView composable before the call to Scaffold().

    @Composable
    fun ArrowBack() {
        IconButton(onClick = { navController.popBackStack() } ) {
            Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(back))
        }
    }

    @Composable
    fun SubmitButton() {
        var isEnabled by rememberSaveable { mutableStateOf(true) }

        IconButton(onClick = {
            isEnabled = false
            postChatt(Chatt(username, message)) {
                getChatts()
            }
            navController.popBackStack()
        }, enabled = isEnabled) {
            Icon(
                Icons.AutoMirrored.Filled.Send,
                stringResource(R.string.send)
            )
        }
    }

We assign to the navigation button a “back arrow” icon we collected earlier from Vector Asset. We give the SubmitButton a paper-plane icon (Icons.Default.Send) and name it, “Send”. When clicked, the button calls postChatt() with a Chatt object instantiated with the values of username and message variables of PostView. Once the user has clicked the Send button, we set isEnabled to false to disable and “grey out” the button. This feature is not as visible in this lab since the sending process completes immediately. In latter labs, when the sending process can take some time, it prevents user from repeatedly clicking the SubmitButton.

Once the chatt is posted, we call getChatts() to retrieve an updated list of chatts from the back end, including chatts other users may have posted since our previous call to getChatts(). Thanks to reactive UI, MainView will update its displayed timeline automatically when the chatts array in ChattStore is updated, without the user having to pull down to refresh.

To ensure the call to getChatts() occurs only after postChatt() has completed (not just that the chatt is sent, but that a response has returned from the sender), we provide a completion function to postChatt() as a trailing lambda. We call getChatts() in this trailing lambda. In the definition of postChatt() below, we see that the completion lambda is run in the callback function of JsonObjectRequest(), which is executed when JsonObjectRequest() is done posting the chatt.

The NavController maintains a stack of visited composables. When you navigate from one composable to another, the NavController pushes the destination composable above the current one on the stack. Once we fire off postChatt(), we pop PostView off the navigation stack to get back to the MainView composable. Depending on the amount to be sent and the state of the network, postChatt() may not have completed when we return to MainView.

We now provide the postChatt() method.

postChatt()

Add the postChatt() method to the ChattStore singleton object:

    fun postChatt(chatt: Chatt, completion: () -> Unit) {
        val jsonObj = mapOf(
            "username" to chatt.username,
            "message" to chatt.message,
        )
        val postRequest = JsonObjectRequest(Request.Method.POST,
            "${serverUrl}postchatt/", JSONObject(jsonObj),
            { completion() },
            { e -> Log.e("postChatt", e.localizedMessage ?: "JsonObjectRequest error") }
        )

        queue.add(postRequest)
    }

In postChatt(), we first assemble together a Kotlin map comprising the key-value pairs of data we want to post to the server. To post it, we create a JsonObjectRequest() with the appropriate POST URL. 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 call JSONObject() to encode it into a serialized JSON object that the server will know how to parse.

If there was no error in posting the chatt, we call the provided completion() lambda.

completion

We designed getChatts() and postChatt() to handle all networking aspects of the labs, including conversion between JSON to Kotlin data. The function getChatts() grabs the current ensemble of chatts from the back end, convert each from JSON to Kotlin data, and store them in the ChattStore.chatts array. Thanks to reactive UI, MainView will update its displayed timeline automatically when the chatts array in ChattStore is updated, without the user having to pull down to refresh. While the Chatt format may be customized to each lab, there is no further lab-specific cleanup functions after the chatts are stored, hence we don’t need to pass any customized completion function to getChatts().

On the other hand, postChatt() grabs the chatt to be posted, in the form of Kotlin data, converts it to a JSON package, and sends it to the back end. After postChatt() has completed posting a chatt, we want to call getChatts() to retrieve the current batch of chatts from the back end. To ensure that getChatts() will return our recently posted chatt, we call it only after postChatt() has completed. We may also need to do some lab-specific cleanup, mainly releasing and resetting various memory resources that should only be done when postChatt() is no longer dependent on them. We ensure that these steps are performed in succession by passing a lab-specific completion() function when calling postChatt(), to be run only after postChatt()’s network operation has completed.

With the POST request created, we submit it to the request queue managed by the Volley networking library for asynchronous execution.

Congratulations! You’re done with the chatter lab!

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. There is no special instructions to run the chatter lab on the Android emulator.

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

Installing your self-signed certificate

Download a copy of chatterd.crt to YOUR*LABS*FOLDER on your laptop. Enter the following commands:

laptop$ cd YOUR*LABS*FOLDER
laptop$ scp -i eecs441.pem ubuntu@YOUR_SERVER_IP:441/chatterd.crt chatterd.crt

Then 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/getchatts/.

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:

<debug-overrides> 
    <trust-anchors> 
        <!-- Trust user added CAs while debuggable only -->
        <certificates src="user" /> 
    </trust-anchors>    
</debug-overrides>  

</network-security-config>

-->

```xml
<?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.

Front-end submission guidelines

We will only grade files committed to the master or main branch (they are the same). If you use multiple branches, please merge them all to the master/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 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 labs. 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 EECS 441 Labs, only files needed for grading will be pushed to GitHub.

  441
    |-- chatter   
        |-- composeChatter
            |-- app
            |-- gradle
    |-- chatterd            
    |-- chatterd.crt 

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 lab 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 eecs441staff@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 Lab Links sheet. The request for teaming information is redundant by design.

References

General

Dev

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

ConstraintLayout and Compose

LiveEdit and @Preview

Themes and Styles

Appendix: imports


Prepared for EECS 441 by Alex Wu, Tiberiu Vilcu, Nowrin Mohamed, Chenglin Li, and Sugih Jamin Last updated: December 10th, 2024