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
HiddenMessage
with a constructor that accepts a string message and aLatLng
location. 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
hasLocationPermissions
that returns true when the app has been granted theACCESS_FINE_LOCATION
permission.private val hasLocationPermissions get() = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
- Write method
syncUnlockButton
to make theunlockMenuItem
visible if and only if there’s a hidden message.private fun syncUnlock() { unlockMenuItem?.isVisible = hiddenMessage != null }
- Write method
promptForGPS
to 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 aGoogleMap
parameter that should be accessible later viamap
. It registers a long-click listener on the map that callspromptForMessage
with the pressed location, enables the map’s My Location widgets if the app has location permissions, and callssyncCircles
to 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
hideMessage
that accepts a string message and aLatLng
location. 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
syncCircles
that 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 serializeHiddenMessage
without 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
promptForMessage
that accepts aLatLng
location parameter. It prompts the user via anAlertDialog
to 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
clearHiddenMessage
that 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
loadHiddenMessage
that loads intohiddenMessage
any 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
unlockMessageSuccess
that pops up a dialog showing the hidden message. It prompts the user to either clear the hidden message or leave it in place. CallclearHiddenMessage
to 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
unlockMessageFailure
that accepts aDouble
distance 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
attemptUnlock
that accepts aLatLng
parameter 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
queryLocationForUnlock
that 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 stringid
and aDiscoveredEndpointInfo
. Its public interface should exposeid
,name
, andtoString
. Bothname
andtoString
should yield the endpoint’s name—which is not the same asid
. - In
HostActivity
, define fieldpayloadListener
as aPayloadCallback
to receive a MIDI message as an array of bytes. Issue the message withKeyboardActivity.sendMidiMessage
. - In
HostActivity
, define fieldconnectionListener
as 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 methodadvertise
to start advertising the device as a “server.” The user should know the status of the advertising. Communicate events using toasts. - In
JoinActivity
, define fieldpayloadListener
as aPayloadCallback
to do absolutely nothing. The server will not be communicating anything back to the client. - In
JoinActivity
, define fieldconnectionListener
as 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 thehostEndpoint
field. - 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 ofEndpoint
and 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 aMutableList
ofEndpoint
and anArrayAdapter
ofEndpoint
. It returns anEndpointDiscoveryCallback
that 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 emptyMutableList
ofEndpoint
, 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
, overridesendMidiMessage
to 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