Tutorial: Maps Compose

Cover Page

DUE Wed, 10/8, 2 pm

The goals of this tutorial are threefold: first, to introduce you to on-device asynchronous event stream in the form of Kotlin’s Flow. Second, to introduce you to Android’s LocationServices and SensorManager APIs and Google Maps API. And third, to implement navigation between multiple screens, including the use of swipe gesture for navigation. We will build on the code base from the second tutorial, chatter.

This tutorial may be completed individually or in teams of at most 2. You can partner differently for each tutorial.

Expected behavior

In the map-augmented Chatter app, we will add a Map View. On the map, there will be one or more markers. Each marker represents a posted chatt. If you click on a marker, it displays the poster’s username, message, timestamp, and their geodata, consisting of their geolocation and velocity (compass-point facing and movement speed), captured at the time the chatt was posted. If a chatt was posted with the user’s geodata, the timeline now shows the chatt with a pin. Clicking the pin brings user to the MapView with the chatt’s posted location marked on the map.

We will also implement a swiping gesture to allow users to switch from the main timeline view to the map view. When a user swipes left to transition from the timeline view to the map view, each retrieved chatts will be displayed as an individual marker on the map. From the map view, users can not post a chatt; they can only return to the timeline view using the back arrow. User also cannot initiate a new retrieval of chatts in the map view.

Post a new chatt and view chatts on MapView:

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.

Preparing your GitHub repo

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

  reactive
    |-- chatterd
    |-- chatterd.crt
    |-- maps
        |-- composeChatter
            |-- app
            |-- gradle
    # and other files or folders

YOUR*TUTORIALS folder on your laptop should contain the chatter.zip file in addition.

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.

Dependencies

Add the following line to your build.gradle (Module:):

dependencies {
    // . . .
    implementation("androidx.compose.material:material-icons-extended:1.7.8")
    implementation("com.google.android.gms:play-services-location:21.3.0")
    implementation("com.google.android.gms:play-services-maps:19.2.0")
    implementation("com.google.maps.android:maps-compose:6.10.0")
    implementation("androidx.navigation3:navigation3-runtime:1.0.0-alpha09")
    implementation("androidx.navigation3:navigation3-ui:1.0.0-alpha09")
    implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:1.0.0-alpha04")
}

and tap on Sync Now on the Gradle menu strip that shows up at the top of the editor screen.

Working with location data as an asynchronous stream

We first look at how to get user’s geolocation information (latitude (lat), longitude (lon), and velocity data (facing and speed)).

We can get continuous updates of the device’s location and bearing from Android’s location and sensor APIs, These APIs rely on callbacks: we have to provide functions that these APIs call whenever there is any location or bearing update. We use callbackFlow to convert the callback into a Kotlin Flow of updates.

Requesting permission

To get device’s location, we must first request user’s permission. In your AndroidManifest.xml file, find android.permisssion.INTERNET and add the following lines right below it:

    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS"
        tools:ignore="HighSamplingRate" />

“Fine” location uses GPS, WiFi, and cell-tower localization to determine device’s location. “Coarse” location uses only WiFi and/or cell-tower localization, with city-block level accuracy.

We will follow up the permission tags added to AndroidManifest.xml above with code to prompt user for access permission, but let us implement the location manager first.

Location Manager

Create a Kotlin file and name it LocManager. Next create a LocManager class. We will request user permission to access fine location in MainActivity later, so we tell Android Studio to ignore the “MissingPermission” error for this class.

You may want to consult the list of imports in the Appendix as there are several plausible import options and you need to import from the right libraries. Rule of thumb: prioritize GMS then location then compose, kotlinx, and finaly android libraries.

In lieu of waiting for the location updates to flow, we initialize our observable location property with an immediate read of the current location. Unfortunately, on a cold start, this “immediate” read could still take 3-5 seconds to yield result.

@SuppressLint("MissingPermission")
class LocManager(context: Context) {
    val locManager = LocationServices.getFusedLocationProviderClient(context)
    private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager

    val permission = mutableStateOf(false)
    var location: State<Location> = mutableStateOf(Location(""))
        private set

    init {
        LocationServices.getFusedLocationProviderClient(context)
            .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, CancellationTokenSource().token)
            .addOnCompleteListener {
                if (it.isSuccessful) {
                    location = mutableStateOf(it.result)
                } else {
                    Log.e("LocManager: getFusedLocation", it.exception.toString())
                }
            }
    }

    // set up location update flow
}

We now use the callback mechanism of FusedLocationProvider to yield an event stream of location updates using callbackFlow. We store the latest location reading in the observable property location above. Replace // set up location update flow with the following extension function to FusedLocationProviderClient:

    private fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
        val locationFeeder = object: LocationCallback() {
            override fun onLocationResult(result: LocationResult) {
                for (item in result.locations) {
                    trySend(item)
                }
            }
        }

        locManager.requestLocationUpdates(
            LocationRequest.Builder(500)
                .setPriority(PRIORITY_HIGH_ACCURACY)
                .build(),
            locationFeeder,
            Looper.getMainLooper()
        ).addOnFailureListener { error ->
            Log.e("locationFlow", error.localizedMessage ?: "listener registration failed")
            close(error) // in case of error, close the Flow
        }

        awaitClose {
            removeLocationUpdates(locationFeeder)
        }
    }.buffer(1, BufferOverflow.DROP_OLDEST) // same as .conflate() or .buffer(Channel.CONFLATED)

    // set up speed reading

The extension function creates a Flow of Location events. It then creates the callback function onLocationResult that emits its argument (a new location result) as the next event in the flow. The callback is encapsulated in a LocationCallback object, as required by the FusedLocationProvider API, and stored in the variable locationFeeder. The property locationFeeder bridges the delegated callback mechanism to Flow. We initialize it with an onLocationResult() callback that yields (trySend()) the next stream element (LocationResult) to the Flow. The function locationFlow() then registers this callback object by calling requestLocationUpdates of the API, which also starts the location updates.

When the flow is closed, the function removes the callback registration from FusedLocationProvider. We set the callbackFlow to buffer only the latest element if updates arrive faster than we can read them (the default is to suspend the emitter if the default buffer of size 64 is full; by specifying BufferOverflow.DROP_OLDEST the emitter becomes non-suspending and the default capacity of non-suspending buffer is 1).

With each location update, we also get a speed update from the device. We add a computed property to read the latest speed. This property is read only when user posts a chatt, so it doesn’t need to be constantly updated and observable. Replace // set up speed reading with:

    val speed: String
        get() = when (location.value.speed) {
            in 0.5..<5.0 -> "walking"
            in 5.0..<7.0 -> "running"
            in 7.0..<13.0 -> "cycling"
            in 13.0..<90.0 -> "driving"
            in 90.0..<139.0 -> "in train"
            in 139.0..<225.0 -> "flying"
            else -> "resting"
        }

    // set up sensor update flow

Next we create two more flows, to read the accelerometer and geomagnetometer updates from the device. We store the latest reading of the accelerometer in the observable property gravity and that of the geomagnetometer in the observable property geomagnetic. Since both types of these sensors can be accessed using the same sensor API, we only need one flow creation extension function to the SensorManager. Replace // set up sensor update flow with:

    private lateinit var gravity: State<FloatArray>
    private lateinit var geomagnetic: State<FloatArray>

    private fun SensorManager.sensorFlow(sensorType: Int) = callbackFlow<FloatArray> {
        val sensorFeeder = object: SensorEventCallback() {
            override fun onSensorChanged(event: SensorEvent) {
                trySend(event.values)
            }
        }

        sensorManager.registerListener(sensorFeeder, getDefaultSensor(sensorType), 5000000)

        awaitClose {
            sensorManager.unregisterListener(sensorFeeder)
        }
    }.conflate()

    // start updates with lifecycle

To start retrieving location and sensor updates from the device, we provide a StartUpdatesWithLifecycle() composable. This must be a composable function since it calls collectAsStateWithLifecycle(), which is a composable function. The composable collectAsStateWithLifecycle automatically closes the flow when the composable is terminated and restarts collection across recompositions and device configuration changes. Replace // start updates with lifecycle with:

    @Composable
    fun StartUpdatesWithLifecycle() {
        location = locManager.locationFlow()
            .collectAsStateWithLifecycle(location.value)
        gravity = sensorManager.sensorFlow(TYPE_ACCELEROMETER)
            .collectAsStateWithLifecycle(FloatArray(3))
        geomagnetic = sensorManager.sensorFlow(TYPE_MAGNETIC_FIELD)
            .collectAsStateWithLifecycle(FloatArray(3))
    }

    // use sensor readings to compute headings

Readings from the accelerometer and geomagneticmeter together allow us to compute the bearing of the device. Next we add properties to store bearing updates, and from these, a computed property that returns the bearing as human-friendly magnetic heading, as opposed to true north, compass direction. Replace // user sensor readings to compute headings with:

    private val compass = arrayOf("North", "NE", "East", "SE", "South", "SW", "West", "NW", "North")
    private val R = FloatArray(9)
    private val I = FloatArray(9)
    private val orientation = FloatArray(3)

    val compassHeading: String
        get() {
            if (::gravity.isInitialized && ::geomagnetic.isInitialized) {
                if (SensorManager.getRotationMatrix(R, I, gravity.value, geomagnetic.value)) {
                    SensorManager.getOrientation(R, orientation)
                    val declination: Double = GeomagneticField(
                        location.value.latitude.toFloat(),
                        location.value.longitude.toFloat(),
                        location.value.altitude.toFloat(),
                        location.value.time
                    ).declination.toDouble()

                    // the 3 elements of orientation: azimuth, pitch, and roll,
                    // true north bearing is azimuth = orientation[0], in rad;
                    // convert to degree and magnetic heading (- declination)
                    val magneticHeading = (Math.toDegrees(orientation[0].toDouble()) - declination + 360.0).rem(360.0)
                    val index = (magneticHeading / 45.0).roundToInt()
                    return compass[index]
                }
            }
            return "unknown"
        }

Accessing and starting the Location Manager

If LocManager is a singleton, we can access it globally as with ChattStore. Unfortunately on Android, it is not simple to create a singleton object that takes arguments in its construction, in our case we need to pass the application context to LocManager. Instead, we create an instance of LocManager in ChattViewModel. Add the following properties to your ChattViewModel in MainActivity.kt:

    val locManager = LocManager(app.applicationContext)
    val selected: MutableState<Chatt?> = mutableStateOf(null)

We will later use the observable selected property to display marker on the map.

Then to your MainActivity class, add the following viewModel property, which we will need to coordinate result of location access permission request:

    private val viewModel: ChattViewModel by viewModels()

In the onCreate() method of your MainActivity prompt user for access permission and, if granted, store it in the location manager. Add the following code in onCreate(), before and outside the setContent{} block:

        registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
            if (!granted) {
                toast("Location access denied")
                finish()
            }
            viewModel.locManager.permission.value = granted
        }.launch(Manifest.permission.ACCESS_FINE_LOCATION)
registerForActivityResult()

We did three things in the above code. First we created a “contract” that informs Android that a certain Activity will be started and the Activity will be expecting input of a certain type and will be returning output of other certain type. This ensures the type safety of starting an Activity for results. In this case, we specified that the Activity we want to start is to request permission, which is a standard Activity for which Android already provides a canned contract with baked-in input/output types.

The second thing we did after creating the “contract” was to register it with the Android OS by calling registerForActivityResult(). As part of the registration process, we provided a callback to handle results from starting the Activity, in the form of a trailing lambda expression. The callback handler will examine the result the permission request. If permission is denied, for the sake of expediency, we simply inform the user permission has been denied with a toast() and end MainActivity, closing the app. In a real app, you may want to be less draconian and let user continue to post text messages.

Since activities can be and are destroyed and re-created, for example everytime the screen orientation changes, if we register an activity result contract in an Activity, as we do here, the registration must be done in the Activity’s onCreate(). This way, every time the Activity is re-created, the contract is re-registered. Alternatively, in composables, we can register activity result contract using rememberLauncherForActivityResult(), which will take care of registering the contract correctly. We will use the compose version in the images lab.

The call to ActivityResultContracts() returns a contract that we can store in a local variable. In this case, since we have no further use of the contract, we didn’t store it in a variable. Instead, we use it directly in the call to registerForActivityResult().

The third thing we did in the above code, was to launch the registered contract to ask access permission to the location sensor (or rather, to ACCESS_FINE_LOCATION). The call to registerForActivityResult() returns a registration handler that we are again not storing in a local variable, but have instead called its launch() method immediately. If we had stored both the contract and the registration handler in local variables, the code above would be the equivalent of:

        val contract = ActivityResultContracts.RequestMultiplePermissions()
        val launcher = registerForActivityResult(contract) { granted ->
            if (!granted) {
                toast("Location access denied")
                finish()
            }
        }
        launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION)              

If the user gives permission to access device location, we immediately start the update flows. Recall that StartUpdatesWithLifecycle() is a composable and thus can only be called from inside the setContent{} block. Inside the setContent{} block, wrap your call to MainView with:

            if (viewModel.locManager.permission.value) {
                viewModel.locManager.StartUpdatesWithLifecycle()

                MainView()
            }

If permission request were denied, we use a Toast to inform the user. A Toast is a small pop-up that appears briefly on screen. Toasts can be very helpful while debugging and to notify users of their current state in the app. Instead of using Toast directly however, we have added a toast() extension to the Context class. The extension allows us to use Toast with some boiler-plate arguments pre-set. By attaching the extension to Context, we can use it anywhere we have access to Context. As the term progresses, we’ll collect all the extensions we’ll be using globally in one file. Create a new Kotlin file called Extensions.kt and put the following code in it:

fun Context.toast(message: String, short: Boolean = true) {
    Toast.makeText(this, message, if (short) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
}

Posting geodata

We can now obtain the user’s lat/lon and heading from Android’s location provider and sensor services. We can post this geodata information with each chatt to an updated Chatter backend that can handle the new APIs.

GeoData

Create a new GeoData class to store the additional geodata in a new GeoData.kt file:

class GeoData(var lat: Double = 0.0, var lon: Double = 0.0, var facing: String = "", var speed: String = "") {
    // content of class
}

Reverse geocoding

In addition to lat/lon data, we use Android’s Geocoder to perform reverse-geocoding to obtain human-friendly place name from the lat/lon data. Add the following methods to your GeoData class in place of // content of class:

    var place = ""
    private suspend fun reverseGeocodeLocation(context: Context) = suspendCoroutine { cont ->
        Geocoder(context, Locale.getDefault()).getFromLocation(lat, lon, 1,
            object: Geocoder.GeocodeListener {
                override fun onError(errorMessage: String?) {}
                override fun onGeocode(addresses: List<Address>) {
                    cont.resume(addresses)
                }
            })
    }

    suspend fun setPlace(context: Context) {
        val geolocs = reverseGeocodeLocation(context)
        place = if (geolocs.isNotEmpty()) {
            geolocs[0].locality ?: geolocs[0].subAdminArea ?: geolocs[0].adminArea ?: geolocs[0].countryName ?: "Place unknown"
        } else { "Place unknown" }
    }

    // pretty print place

We will call setPlace() before posting a chatt to compute and include the place name in the posted chatt.

To present the geodata in a nicely formatted string, add the following computed property to your GeoData struct, replacing the comment, // pretty print place. We will use this property to display the geodata information to the user.

    val postedFrom: AnnotatedString
        get() = buildAnnotatedString {
            append("Posted from ")
            pushStyle(SpanStyle(color = Moss, fontWeight = FontWeight.Bold))
            append(place)
            pop()
            append(" while facing ")
            pushStyle(SpanStyle(color = Moss, fontWeight = FontWeight.Bold))
            append(facing)
            pop()
            append(" moving at ")
            pushStyle(SpanStyle(color = Moss, fontWeight = FontWeight.Bold))
            append(speed)
            pop()
            append(" speed.")
            toAnnotatedString()
        }

Chatt

Add a new stored property geodata to the Chatt class to hold the geodata associated with each chatt.

class Chatt(var username: String? = null,
            var message: MutableState<String>? = null,
            var id: UUID? = null,
            var timestamp: String? = null,
            var geodata: GeoData? = null)

ChattStore

We update postChatt() to handle geodata. First, we serialize the geodata into a JSON Array, right below the postChatt() function signature:

    suspend fun postChatt(chatt: Chatt, errMsg: MutableState<String>) {
        val geoObj = chatt.geodata?.run{ JSONArray(listOf(lat, lon, facing, speed)) }
        
        // put geoObj into serialize chatt
    }

then we serialize the chatt to be posted, with the serialized geoObj inside it, update the API endpoint to the new postmaps URL:

        val jsonObj = mapOf(
            "username" to chatt.username,
            "message" to chatt.message?.value,
            "geodata" to geoObj?.toString()
        )
        val requestBody = JSONObject(jsonObj).toString()
            .toRequestBody("application/json".toMediaType())

        val apiUrl = "${serverUrl}/postmaps"

        // create and post request

The rest of the function to create and post the request remains the same. Keep the existing code.

Next we update getChatts(). First update the API endpoint to the new getmaps URL. Replace getchatts to getmaps on this line:

        val apiUrl = "${serverUrl}/getmaps"

then replace the call to chatts.add() with:

                    val geoArr = if (chattEntry[4] == JSONObject.NULL) null else JSONArray(chattEntry[4] as String)
                    chatts.add(
                        Chatt(
                            username = chattEntry[0].toString(),
                            message = mutableStateOf(chattEntry[1].toString()),
                            id = UUID.fromString(chattEntry[2].toString()),
                            timestamp = chattEntry[3].toString(),
                            geodata = geoArr?.let {
                                GeoData(
                                    lat = it[0].toString().toDouble(),
                                    lon = it[1].toString().toDouble(),
                                    facing = it[2].toString(),
                                    speed = it[3].toString()
                                )
                            }
                        )
                    )

The UI

Now we tackle the UI.

Posting geodata

To remind user that their location information will be sent along with their chatt, we display the user’s lat/lon alongside the text input box. In MainView, add a label argument to your OutlinedTextField so that the lat/lon is displayed above the OutlinedTextField. Add the label below the lineLimits parameter:

                        label = {
                            Text(text = "lat/lon: ${vm.locManager.location.value.latitude}/${vm.locManager.location.value.longitude}",
                                color = Color.Gray,
                                modifier = Modifier
                                    .background( HeavenWhite)
                                    .padding(start = 6.dp, end = 4.dp)
                            )
                        },

Then in your SubmitButton, replace the call to postChatt() with the following:

                val geodata= GeoData(
                    lat = vm.locManager.location.value.latitude,
                    lon = vm.locManager.location.value.longitude,
                    facing = vm.locManager.compassHeading,
                    speed = vm.locManager.speed
                )
                postChatt(Chatt(vm.username,
                    mutableStateOf(vm.message.text.toString()),
                    geodata = geodata,
                ), vm.errMsg)

Displaying geodata

To navigate to another screen to show the map, we first need to set up navigation.

In MainActivity.kt, create the following global variables:

val LocalNavStackController = staticCompositionLocalOf<SnapshotStateList<NavKey>> { error("LocalNavStackController provides no current")}
data object NavMain: NavKey
data object NavMap: NavKey

Then in your MainActivity class, inside the setContent {} block, replace MainView() with:

                val navStack = remember { mutableStateListOf<NavKey>(NavMain)}

                CompositionLocalProvider(LocalNavStackController provides navStack) {
                    NavDisplay(
                        backStack = navStack,
                        onBack = { navStack.removeLastOrNull() },
                        entryProvider = { key ->
                            when (key) {
                                is NavMain -> NavEntry(key) { MainView() }
                                is NavMap -> NavEntry(key) { MapView() }
                                else -> { error("unknown route: $key") }
                            }
                        }
                    )
                }
Prop drilling vs. State hoisting

The app will have one instant of the navStack. We could pass navStack to every View that needs it, their child-Views, and so on down the hierarchy of the View tree. In React this is called “prop drilling” as the HTML properties needed to render the UI are passed down and down to the bottom of the UI hierarchy, even if some intermediate components do not need access to these properties.

Alternatively, we can “hoist” the needed state to the top of the UI sub-tree (which may be the root of the tree in the limit) and have each UI component needing the state data search up its UI sub-tree until it finds the state. The state is said to be “provided” to the sub-tree. The Provider usually maintains a look-up table of available states, identifiable by the type of the state. When the same data type is provided at different levels of the UI-tree, the one lowest in the hierarchy above the component searching for the state will match.

The states or values of environment objects are scoped to the sub-tree where the data is provided. The advantage of using an environment object is that we don’t have to pass/drill it down a sub-tree yet Views in the sub-tree can subscribe and react to changes in the object.

Locating chatt

First add to the strings resource, /app/res/values/strings.xml:

    <string name="pindrop">pin drop</string>
    <string name="map">map</string>
    <string name="back">Back</string>

We don’t display geodata information directly on the text bubble. Instead, if a chatt has geodata information, we display a pin in its bubble, next to the message. Due to the alternating alignment display of text bubbles, depending on whether the user is the poster, we put the pin icon on the right or left of the message, to go with the text bubble alignment. In ChattScrollView.kt, first add the following local variable to the ChattView() composable:

    val navStack = LocalNavStackController.current

then wrap the Text displaying the message in a Row so that a map pin icon is displayed either to its left (isSender) or right (!isSender). Note that we move the modifiers for the text bubble to encompass the whole Row:

                Row(
                    verticalAlignment = Alignment.Top,
                    modifier = Modifier
                        .shadow(2.dp, shape = RoundedCornerShape(20.dp))
                        .background(if (isSender) Chartreuse else HeavenWhite)
                        .padding(12.dp)
                        .widthIn(min = 0.dp, max = 300.dp)
                ) {
                    if (isSender && chatt.geodata != null) {
                        IconButton(
                            onClick = {
                                vm.selected.value = chatt
                                navStack.add(NavMap)
                            },
                            modifier = Modifier
                                .padding(end = 8.dp)
                                .size(25.dp)
                                .align(Alignment.Top)
                        ) {
                            Icon(
                                Icons.Default.PinDrop,
                                contentDescription = stringResource(R.string.pindrop),
                                tint = Moss
                            )
                        }
                    }
                    Text(
                        text = msg.value,
                        style = MaterialTheme.typography.bodyLarge,
                    )
                    if (!isSender && chatt.geodata != null) {
                        IconButton(
                            onClick = {
                                vm.selected.value = chatt
                                navStack.add(NavMap)
                            },
                            modifier = Modifier
                                .padding(start = 8.dp)
                                .size(25.dp)
                                .align(Alignment.Top)
                        ) {
                            Icon(
                                Icons.Default.PinDrop,
                                contentDescription = stringResource(R.string.pindrop),
                                tint = Moss
                            )
                        }
                    }
                }

When the user taps on a pin icon, we say that the associated chatt is selected and store that in our view model in the button’s onClick lambda expression. Then we navigate to the map screen.

Google Maps API key

  1. To display geodata associated with chatts on a map, we first must obtain a Google Maps API key:

    Step 1.Set up your project and click on the big blue Go to the project selector page button.

    Step 2. Enable APIs or SDKs, click on the big blue Enable the Maps SDK for Android button.

    Step 3. Get an API Key and follow the big blue Go to the Credentials page button (you will need a gmail address, not a umich email address, to set up a Google Cloud Console account).

    :point_right:DO NOT follow the rest of the instructions on the page. STOP at Add the API Key to your app. Follow the instructions below instead.

    The Google API website is reconfigured very frequently. The instructions here have been through at least 4 reconfigurations of the site. If what you see on the site is so totally different from the description here that you can’t make your way through it, please let the teaching staff know.

  2. Optional: set restrictions on your API key before using it in production (i.e., not necessary for this tutorial): create an Android-restricted and API-restricted API key for your project.

    SHA-1 fingerprint

    To get your SHA-1 signing certificate:

    On macOS on Terminal, enter:

    laptop$ keytool -v -list -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
    

    On Windows PowerShell, enter:

    PS laptop> keytool -v -list -keystore ~\.android\debug.keystore -alias androiddebugkey -storepass android -keypass android
    
    Don't have keytool or Java Runtime?

    On macOS, if keytool complains that operation couldn’t be completed because it is unable to locate a Java Runtime, download and install it from www.java.com.

    To install keytool on Windows natively, you can follow the instructions on “How to fix keytool is not recognized as an internal or external command” [thanks to Konstantin Kovalchuk (F21)].

    If you’re using WSL as terminal but run Android Studio as a Windows application instead of a Linux application, you must point keytool to /mnt/c/users/<YOUR_WINDOWS_USERNAME>/.android/debug.keystore.

    If you don’t have keytool installed on WSL, you can install it with [thanks to David Wegsman F21]:

      laptop$ sudo apt install openjdk-17-jre-headless
    

    You should see amongst the output three lines that start like this:

    Certificate fingerprints:
             SHA1: XX:XX:XX:...:XX
             SHA256: YY:YY:YY:...:YY
    

    Cut and paste the SHA1 certificate to the Google API Console. Click CREATE.

  3. Make a copy of the resulting API key and put a copy of your API key in your AndroidManifest.xml, right before the closing </application> tag. The com.google.android.geo.API_KEY meta-data block in your AndroidManifest.xml should look like:

            <meta-data
                android:name="com.google.android.geo.API_KEY"
                android:value="AIz..." />
    

    where AIz... should be replaced with your API key. This is not the most secure way of storing your API key, but it makes grading easier.

MapView

We use MapView to show a poster’s location when the user clicked on a pin icon as described. But we want MapView to also be able to show the locations of all the chatts retrieved from the backend.

Create a new Kotlin file MapView.kt. In the MapView() composable, first set where the map camera is pointing. If the user has clicked on a pin icon, set the camera to point to the posting location of the selected chatt. Otherwise, set it to point to the user’s current location.

@Composable
fun MapView() {
    val vm: ChattViewModel = viewModel()
    val navStack = LocalNavStackController.current
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    val cameraPosition = vm.selected.value?.geodata?.let {
        LatLng(it.lat, it.lon)
    } ?: LatLng(vm.locManager.location.value.latitude,
        vm.locManager.location.value.longitude)

    // show map

    // show back arrow and handle predictive back gesture
}

In rendering the Map, we center the map where the camera is pointing at (lat/lon), at the given zoom level (distance and height), and tilt (angle) of the camera. We’ve chosen to show the map as satellite image, overlaid with street grid. The map also shows a compass and the user’s current location. We first implement putting a marker at the selected poster’s location. Replace // show map with:

    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        properties = MapProperties(mapType = MapType.HYBRID, isMyLocationEnabled = true),
        uiSettings = MapUiSettings(compassEnabled = true, mapToolbarEnabled = false),
        cameraPositionState = rememberCameraPositionState {
            position = CameraPosition.Builder().target(cameraPosition).zoom(18f).tilt(60f).build()
        }
    ) {
        vm.selected.value?.let { chatt ->
            chatt.geodata?.run {
                if (place == "") { scope.launch { setPlace(context) } }
                MarkerInfoWindow(
                    state = MarkerState(position = cameraPosition),
                    infoWindowAnchor = Offset(1f, 3f),
                ) {
                    InfoView(chatt)
                }
            }
        // or positions of all chatts
        }
    }

GoogleMap() is a wrapper around a legacy, XML-based Android View, with limited reactivity. Hence we need to call geodata.setPlace() before initializing MarkerInfoWindow().
InfoWindow doesn’t automatically re-render if geodata.place changed after initialization.

If no particular chatt were selected, we show the position of all retrieved chatts. We loop through the chatts array in ChattStore and display a marker for each chatt, at the coordinates (lat/lon) the chatt was posted from. Replace // or positions of all chatts with:

        } ?: run {
            chatts.forEach { chatt ->
                chatt.geodata?.run {
                    if (place == "") { scope.launch { setPlace(context) } }
                    MarkerInfoWindow(
                        state = MarkerState(position = LatLng(lat, lon)),
                        infoWindowAnchor = Offset(1f, 3f),
                        icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_CYAN),
                    ) {
                        InfoView(chatt)
                    }
                }
            }

Finally, to allow user to return to the timeline, we implement a Back arrow and a BackHandler for Android’s PredictiveBackGesture triggered by right swiping. Replace // show back arrow and handle predictive back gesture comment at the end of the MapView() composable above:

    IconButton(
        onClick = {
            navStack.removeAll(navStack.drop(1))
        },
        colors = IconButtonDefaults.iconButtonColors(
            containerColor = Color.White.copy(alpha = 0.6f,)),
        modifier = Modifier.offset(x = 8.dp, y = 50.dp)
    ) {
        Icon(
            Icons.AutoMirrored.Filled.ArrowBack,
            contentDescription = stringResource(R.string.back),
            tint = DarkGreen90,
            modifier = Modifier.scale(1.25f)
        )
    }

    BackHandler(true) { // PredictiveBackGesture
        navStack.removeAll(navStack.drop(1))
    }

If the user further taps on a marker shown, we display an info window showing the chatt associated with that marker. Here’s the definition of InfoView composable used to display a chatt on the map. Put it outside your MapView() composable:

@Composable
fun InfoView(chatt: Chatt) {
    Column(modifier = Modifier.padding(4.dp, 0.dp, 4.dp, 0.dp)
        .background(color = Color.LightGray.copy(alpha = 0.8f))) {
        Row(horizontalArrangement = Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth(.8f)) {
            chatt.username?.let { Text(it, fontSize = 16.sp, modifier = Modifier.padding(4.dp, 0.dp, 4.dp, 0.dp)) }
            chatt.timestamp?.let { Text(it, fontSize = 12.sp, textAlign = TextAlign.End, modifier = Modifier.padding(4.dp, 0.dp, 4.dp, 0.dp)) }
        }
        chatt.message?.value?.let { Text(it, fontSize = 14.sp, modifier = Modifier.padding(4.dp, 2.dp, 4.dp, 0.dp).fillMaxWidth(.8f)) }
        chatt.geodata?.let { Text(it.postedFrom, fontSize = 12.sp, modifier = Modifier.padding(4.dp, 4.dp, 4.dp, 0.dp).fillMaxWidth(.8f)) }
    }
}

Displaying the geodata of all chatts

To display the geodata of all chatts, first add the following local variable to your MainView() composable:

    val navStack = LocalNavStackController.current

Next to add a map button to the title bar of the timeline screen, add the following argument to the TopAppBar of Scaffold() in your MainView(), e.g., after the title parameter, before the color parameter:

                actions = {
                    IconButton(onClick = {
                        vm.selected.value = null
                        navStack.add(NavMap)
                    },
                        modifier = Modifier
                            .padding(end = 20.dp)
                            .size(55.dp)
                            .background(
                                Gray88,
                                shape = CircleShape
                            )
                            .border(width = 1.dp, color = Color.Transparent,
                                shape = CircleShape)
                            .align(Alignment.CenterVertically)
                    ) {
                        Icon(
                            Icons.Default.Map,
                            contentDescription = stringResource(R.string.map),
                            modifier = Modifier
                                .size(100.dp)
                                .padding(10.dp),
                            tint = Moss
                        )
                    }
                },

To allow user to swipe left, in lieu of clicking the map button, to view all chatts, add the following modifier to Scaffold(), after the pointerInput() modifier:

            .scrollable(
                orientation = Orientation.Horizontal,
                state = rememberScrollableState { delta ->
                    when {
                        delta < 0 -> {
                            vm.selected.value = null
                            navStack.add(NavMap)
                        }
                        else -> {}
                    }
                    delta
                }),

the action when user clicks on the map button or swipe left is similar the action for the pin icons above, though here we specify that no chatt has been selected.

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

Run and test to verify and debug

Once you have your backend setup, run your front end against your back end. Due to location privacy concerns mada.eecs.umich.edu is not available for this tutorial. You will not get full credit if your front end is not set up to work with your backend!

You can simulate posting from multiple locations by simulating your location, both on the emulator and on device.

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:

:point_right: Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo under the folder maps. 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 and, further, you may 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
    |-- chatterd
    |-- chatterd.crt
    |-- maps
        |-- composeChatter
            |-- app
            |-- gradle
    # and other files or folders   

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 build, run, or open.

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 tutorial’s, please update your entry. If you’re using a different GitHub repo from previous tutorial’s, invite eecsreactive@umich.edu to your new GitHub repo and update your entry.

References

Activity Results

ViewModel

ViewModels in Compose

Composition Local

Flow

Appendix: imports


Prepared for EECS 441 by Wendan Jiang, Alexander Wu, Benjamin Brengman, Ollie Elmgren, Nowrin Mohamed, Chenglin Li, Xin Jie ‘Joyce’ Liu, Yibo Pi, and Sugih Jamin | Last updated: August 13th, 2025