SENG 440: Lecture 18 – Location
Dear students,
Today we write an app for hiding and finding messages in geographic locations. Our primary goal is to explore how to acquire and use a device’s location, and this app is just one playful example of the many possible applications that are aware of the user’s place.
Next lecture we will discuss communicating with local devices using Google’s Nearby API. See the TODO below for the assigned exercises.
Hideaway
Prior to this lecture, I have added the following dependencies to my module’s build.gradle:
implementation 'com.google.android.gms:play-services-maps:16.1.0' implementation 'com.google.maps.android:android-maps-utils:0.5+' implementation 'com.google.code.gson:gson:2.8.5'
Additionally, I have created a values/google_maps_api.xml file containing an API key granted by Google. Such keys are necessary to access many web services. But they generally should not be included in your VCS repositories. A malicious user could acquire your key and masquerade as your app, possibly racking up fees that will be charged to you as the key owner. This is a vulnerability even for installed apps that store the key. If you plan on publishing an app that relies on an API key, you should do some reading up on the dangers.
Following are the exercises assigned last time. We will use and discuss the solutions that you’ve submitted as we assemble our app, but I include my solutions here for reference.
- Write class
HiddenMessagewith a constructor that accepts a string message and aLatLnglocation. It also creates an empty but mutable list of guessed locations namedguesses. All three values are publicly accessible.data class HiddenMessage(val message: String, val location: LatLng) { val guesses = mutableListOf<LatLng>() override fun toString() = "$message | $location | $guesses" }
- Write getter-only private property
hasLocationPermissionsthat returns true when the app has been granted theACCESS_FINE_LOCATIONpermission.private val hasLocationPermissions get() = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
- Write method
syncUnlockButtonto make theunlockMenuItemvisible if and only if there’s a hidden message.private fun syncUnlock() { unlockMenuItem?.isVisible = hiddenMessage != null }
- Write method
promptForGPSto show a dialog if no GPS provider is enabled. Prompt the user to open the location settings or cancel. Use the simpler Android API, not Google Play Services.private fun promptForGPS() { val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { AlertDialog.Builder(this).apply { setMessage("GPS is not enabled on your device. Enable it in the location settings.") setPositiveButton("Settings") { _, _ -> startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } setNegativeButton("Cancel") { _, _ -> } show() } } }
- Write method
onMapReady, which accepts aGoogleMapparameter that should be accessible later viamap. It registers a long-click listener on the map that callspromptForMessagewith the pressed location, enables the map’s My Location widgets if the app has location permissions, and callssyncCirclesto plot the circles of any hidden message that’s currently being hunted.@SuppressLint("MissingPermission") override fun onMapReady(googleMap: GoogleMap) { map = googleMap map.setOnMapLongClickListener { promptForMessage(it) } if (hasLocationPermissions) { map.setMyLocationEnabled(true) } syncCircles() }
- Write method
hideMessagethat accepts a string message and aLatLnglocation. It tracks the hidden message inhiddenMessage, saves it to a file, synchronizes the unlock button, and synchronizes the circles on the map.private fun hideMessage(message: String, location: LatLng) { hiddenMessage = HiddenMessage(message, location) saveHiddenMessage() syncUnlock() syncCircles() }
- Write method
syncCirclesthat clears the map of any existing circles. If a message is currently hidden, it plots hollow circles for each of the message’s guessed locations. Each circle is centered at its guessed location, and its radius is the number of meters between the guessed location and the location of the hidden message.private fun syncCircles() { map.clear() hiddenMessage?.let { it.guesses.forEach { guess -> val distance = SphericalUtil.computeDistanceBetween(it.location, guess) map.addCircle(CircleOptions().apply { center(guess) radius(distance) strokeColor(Color.RED) }) } } }
- Write method
saveHiddenMessage, which writes out in JSON the current hidden message tohidden.json, a private file on internal storage. Use Gson to produce the JSON. Gson knows how to serializeHiddenMessagewithout any hints from you.private fun saveHiddenMessage() { val json = Gson().toJson(hiddenMessage) openFileOutput("hidden.json", Context.MODE_PRIVATE).use { writer -> writer.write(json.toByteArray()) } }
- Write method
promptForMessagethat accepts aLatLnglocation parameter. It prompts the user via anAlertDialogto enter a string. When the user positively dismisses the dialog, it callshideMessage.private fun promptForMessage(location: LatLng) { AlertDialog.Builder(this).apply { setTitle("What message will you hide here?") val view = layoutInflater.inflate(R.layout.message_view, null) setView(view) setPositiveButton("Hide") { _, _ -> val messageEditor: EditText = view.findViewById(R.id.messageEditor) hideMessage(messageEditor.text.toString(), location) } show() } }
- Write method
clearHiddenMessagethat removes all traces of any currently hidden message, deleting the file saved earlier bysaveHiddenMessage, clearing any circles from the map, and hiding the unlock button from the action bar.private fun clearHiddenMessage() { hiddenMessage = null map.clear() deleteFile("hidden.json") syncUnlock() }
- Write method
loadHiddenMessagethat loads intohiddenMessageany hidden message previously saved bysaveHiddenMessage. Use Gson.private fun loadHiddenMessage() { try { openFileInput("hidden.json").use { reader -> val json = String(reader.readBytes()) hiddenMessage = Gson().fromJson(json, HiddenMessage::class.java) } } catch (e: FileNotFoundException) { } syncUnlock() }
- Write method
unlockMessageSuccessthat pops up a dialog showing the hidden message. It prompts the user to either clear the hidden message or leave it in place. CallclearHiddenMessageto clear it.private fun unlockMessageSuccess() { hiddenMessage?.let { AlertDialog.Builder(this).apply { setTitle("You unlocked a message!") setMessage(it.message) setPositiveButton("Clear") { _, _ -> clearHiddenMessage() } setNegativeButton("Leave") { _, _ -> } show() } } }
- Write method
unlockMessageFailurethat accepts aDoubledistance parameter. It pops up a dialog that tells the user how far away in kilometers they are from the hidden message. Limit the number of digits after the decimal point.private fun unlockMessageFailure(distance: Double) { AlertDialog.Builder(this).apply { setTitle("Too far away") setMessage(String.format("You are %.1fk away from the hiddenMessage message.", distance)) setPositiveButton("OK") { _, _ -> } show() } }
- Write method
attemptUnlockthat accepts aLatLngparameter for a guessed location. It adds the guess to the current hidden message, synchronizes the circles on the map, and determines the distance to the hidden message. If the distance is under 500m or so, callunlockMessageSuccess. Otherwise, callunlockMessageFailure.private fun attemptUnlock(guessLocation: LatLng) { hiddenMessage?.let { it.guesses.add(guessLocation) syncCircles() val distance = SphericalUtil.computeDistanceBetween(guessLocation, it.location) if (distance < 500.0) { unlockMessageSuccess() } else { unlockMessageFailure(distance) } } }
- Write method
queryLocationForUnlockthat creates and registers aLocationListener. Listen only for location updates from GPS. As soon a location is received, attempt to unlock the hidden message and unregister the listener.@SuppressLint("MissingPermission") private fun queryLocationForUnlock() { val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager val listener = object : LocationListener { override fun onLocationChanged(location: Location) { hiddenMessage?.let { attemptUnlock(LatLng(location.latitude, location.longitude)) } locationManager.removeUpdates(this) } override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?) {} override fun onProviderEnabled(p0: String?) {} override fun onProviderDisabled(p0: String?) {} } if (hasLocationPermissions) { locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0f, listener) } }
Now we can hide messages in places. The circles on the map perhaps make find the messages too easy. But easy is the point of mobile devices, isn’t it?
TODO
First, please read up on the Nearby Connections API.
Our next app is called P2Piano, which presents a piano interface across two devices and makes use of Nearby. The primary device plays the fourth octave, and the secondary devices plays the fifth octave. I will provide the user interface and the code to generate the music using MIDI commands. I’ll have third-party app FluidSynth running at the same time. It interprets MIDI commands and turns them into sound.
The main activity presents three buttons, one to play the piano solo, one to host a shared piano, and one to join a hosted piano. These buttons will fire up secondary activities. The solo KeyboardActivity knows nothing about other devices. By itself, it only plays music on the local device. You will subclass KeyboardActivity to behave differently when devices are paired. Its implementation is not terribly important, but I include it here for reference:
open class KeyboardActivity : PermittedActivity() {
private var midiDevice: MidiDevice? = null
private var port: MidiInputPort? = null
protected var octave: Int = 4
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_keyboard)
// The keys are just vanilla Views arranged and colored to look like a
// keyboard.
val keys: List<View> = listOf(
findViewById(R.id.c),
findViewById(R.id.cd),
findViewById(R.id.d),
findViewById(R.id.de),
findViewById(R.id.e),
findViewById(R.id.f),
findViewById(R.id.fg),
findViewById(R.id.g),
findViewById(R.id.ga),
findViewById(R.id.a),
findViewById(R.id.ab),
findViewById(R.id.b)
)
keys.forEach { key ->
// Each view has its semitone/halfstep offset set in its tag attribute.
// C is 0, and B is 11.
val halfstep = key.tag.toString().toInt()
// The keys' drawable is a selector that toggles between the key's color
// when not pressed and cornflower blue when pressed. We must set the
// selector state explicitly since we are using vanilla views.
key.setOnTouchListener { view, event ->
val midiNumber = (12 * (octave + 1) + halfstep).toByte()
when (event.action) {
MotionEvent.ACTION_DOWN -> {
view.isPressed = true
sendNoteMessage(midiNumber, 127)
true
}
MotionEvent.ACTION_UP -> {
view.isPressed = false
sendNoteMessage(midiNumber, 0)
true
}
else -> false
}
}
}
}
private fun setupMidi() {
val midiManager = getSystemService(Context.MIDI_SERVICE) as MidiManager
val infos = midiManager.devices
midiManager.openDevice(infos[0], {
midiDevice = it
port = it.openInputPort(0)
}, null)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.actionbar, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.setup_midi_button -> {
setupMidi()
true
}
else -> super.onOptionsItemSelected(item)
}
private fun sendNoteMessage(midiNumber: Byte, velocity: Byte) {
// A note message is three bytes. The most-significant nibble of the first
// byte is 1001 (0x90) and the least is the channel in 0-15. The second
// byte is the note's MIDI number. C4 is 60, and C5 is 72. The byte is
// the velocity or force of the note. 0 means stop playing.
val buffer = byteArrayOf(0x90.toByte(), midiNumber, velocity)
sendMidiMessage(buffer)
}
protected open fun sendMidiMessage(bytes: ByteArray) {
port?.send(bytes, 0, bytes.size)
}
}
The HostActivity is a subclass of KeyboardActivity and will eventually serve as the piano “server.” But it starts off like this:
class HostActivity : KeyboardActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
requestPermissions(permissions, 100, {
advertise()
}, {
Toast.makeText(this, "Location not permitted.", Toast.LENGTH_LONG).show()
})
}
}
Similarly, JoinActivity is a subclass of KeyboardActivity and will eventually join a piano server on another device. But it starts off like this:
class JoinActivity : KeyboardActivity() {
private var hostEndpoint: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
octave = 5
val permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
requestPermissions(permissions, 100, {
discover()
}, {
Toast.makeText(this, "Location not permitted.", Toast.LENGTH_LONG).show()
})
}
}
The exercises that you will collectively complete are listed below. Check your email for your assigned exercise and a link to submit your solution. Become an expert on your particular corner of the app, investigating background material as needed. Build on top of the activity and others’ code as you complete your exercise. No solution should be very long, but some have more overhead like curly braces and method signatures than others.
- Write class
Endpoint, which accepts as parameters a stringidand aDiscoveredEndpointInfo. Its public interface should exposeid,name, andtoString. BothnameandtoStringshould yield the endpoint’s name—which is not the same asid. - In
HostActivity, define fieldpayloadListeneras aPayloadCallbackto receive a MIDI message as an array of bytes. Issue the message withKeyboardActivity.sendMidiMessage. - In
HostActivity, define fieldconnectionListeneras aConnectionLifecycleCallback. Be prepared to explain the role of its three methods. Automatically accept any attempted connection. The user should know the status of the connection. Communicate events using toasts. - In
HostActivity, define methodadvertiseto start advertising the device as a “server.” The user should know the status of the advertising. Communicate events using toasts. - In
JoinActivity, define fieldpayloadListeneras aPayloadCallbackto do absolutely nothing. The server will not be communicating anything back to the client. - In
JoinActivity, define fieldconnectionListeneras aConnectionLifecycleCallback. Be prepared to explain the role of its three methods. Automatically accept any attempted connection. The user should know the status of the connection. Communicate events using toasts. Once a connection has been establishled, retain the endpoint’s ID in thehostEndpointfield. - In
JoinActivity, define methodjoinHost, which accepts anEndpoint. Request a connection to the specified host endpoint. The user should know the status of this request. Communicate events using toasts. - In
JoinActivity, define methodshowEndpointChooser, which accepts a list ofEndpointand returns anArrayAdapter<Endpoint>. The endpoints in the list are possible hosts that can be joined. The method creates an adapter for the list and then displays it anAlertDialog. When an endpoint from the adapter is clicked on, join it. When the dialog is dismissed, stop discovering new hosts. - In
JoinActivity, define methodcreateDiscoverListener, which accepts aMutableListofEndpointand anArrayAdapterofEndpoint. It returns anEndpointDiscoveryCallbackthat adds newly found endpoints to the list and removes lost endpoints from the list, notifying the adapter of any changes. - In
JoinActivity, define methoddiscover. It creates an emptyMutableListofEndpoint, pops open an endpoint chooser, and creates a listener for found and lost endpoints. It starts trying to discover hosts. The user should know the status of the discovery. Communicate events using toasts. - In
JoinActivity, overridesendMidiMessageto not only call the superclass version of the method, but also send it to the paired host, if there is one.
See you next time!
P.S. It’s time for a haiku!
Groomed path suggester
Geographic pin sticker
Good parking spotter