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
- On your laptop, navigate to
YOUR*TUTORIALS/ - Unzip your
chatter.zipfile. Double check that you still have a copy of the zip file for future reference! - Rename your newly unzipped
chatterfolder**maps** - Remove your maps’s
.gradledirectory by running in a shell window:laptop$ cd YOUR*TUTORIALS/maps/composeChatter laptop$ rm -rf .gradle - Push your local
YOUR*TUTORIALS/repo to GitHub and make sure there’re no git issues:<summary>git push</summary> - Open GitHub Desktop and click on `Current Repository` on the top left of the interface - Click on your assignment GitHub repo - Add Summary to your changes and click `Commit to main` - If you have pushed other changes to your Git repo, click `Pull Origin` to synch up the clone on your laptop - Finally click on `Push Origin` to push changes to GitHub
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.
Navigating between screens
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
-
To display geodata associated with
chatts on a map, we first must obtain a Google Maps API key:Step 1.
Set up your projectand click on the big blueGo to the project selector pagebutton.Step 2.
Enable APIs or SDKs, click on the big blueEnable the Maps SDK for Androidbutton.Step 3.
Get an API Keyand follow the big blueGo to the Credentials pagebutton (you will need a gmail address, not a umich email address, to set up a Google Cloud Console account).
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.
-
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 androidOn Windows PowerShell, enter:
PS laptop> keytool -v -list -keystore ~\.android\debug.keystore -alias androiddebugkey -storepass android -keypass androidDon't have keytool or Java Runtime?
On macOS, if
keytoolcomplains that operation couldn’t be completed because it is unable to locate a Java Runtime, download and install it fromwww.java.com.To install
keytoolon Windows natively, you can follow the instructions on “How to fixkeytoolis 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
keytoolto/mnt/c/users/<YOUR_WINDOWS_USERNAME>/.android/debug.keystore.If you don’t have
keytoolinstalled on WSL, you can install it with [thanks to David Wegsman F21]:laptop$ sudo apt install openjdk-17-jre-headlessYou should see amongst the output three lines that start like this:
Certificate fingerprints: SHA1: XX:XX:XX:...:XX SHA256: YY:YY:YY:...:YYCut and paste the SHA1 certificate to the Google API Console. Click
CREATE. -
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. Thecom.google.android.geo.API_KEYmeta-data block in yourAndroidManifest.xmlshould 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:
- Open GitHub Desktop and click on
Current Repositoryon the top left of the interface - Click on the GitHub repo you created at the start of this tutorial
- Add Summary to your changes and click
Commit to mainat the bottom of the left pane - If you have pushed code to your repo, click
Pull Originto synch up the repo on your laptop - Finally click
Push Originto push all changes to GitHub
Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo under the folder 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
- Activity Results API: A better way to pass data between Activities
- Deep Dive into Activity Results API – No More onActivityResult()
ViewModel
- ViewModel Overview
- The lifecycle of a ViewModel
- ViewModels: A Simple Example
- Recommended Ways to Create ViewModel or AndroidViewModel and errata
ViewModels in Compose
- ViewModels in Compose
- Jetpack Compose navigation architecture with ViewModels
- Handling lifecycle events on Jetpack Compose
Composition Local
- Locally scoped data with CompositionLocal
- Jetpack Compose: Static vs Dynamic CompositionLocals — Reads, Writes and Trade Offs
- How to get Activity from Jetpack Compose
Flow
- Simplifying APIs with Coroutines and Flow
- 7 Useful Ways to create Flow in Kotlin
- Consuming Flows Safely in Jetpack Compose
- Get Started With Google Maps
- Compose for the Maps SDK for Android Now Available
- Road level details for Google Maps Platform
- MarkerInfoWindow
- Getting City Name of Current Position
- Android Location Providers
- How do I get the current GPS location programmatically in Android?
- FusedLocationProviderClient doesn’t have bearing data, note rad to degree conversion
- AnnotatedString SpanStyle
- Gestures in Jetpack Compose: The Basics
- Navigation 3: Get Started
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