teaching machines

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.

  1. Write class HiddenMessage with a constructor that accepts a string message and a LatLng location. It also creates an empty but mutable list of guessed locations named guesses. 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"
    }
    
  2. Write getter-only private property hasLocationPermissions that returns true when the app has been granted the ACCESS_FINE_LOCATION permission.
    private val hasLocationPermissions
      get() = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
    
  3. Write method syncUnlockButton to make the unlockMenuItem visible if and only if there’s a hidden message.
    private fun syncUnlock() {
      unlockMenuItem?.isVisible = hiddenMessage != null
    }
    
  4. 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()
        }
      }
    }
    
  5. Write method onMapReady, which accepts a GoogleMap parameter that should be accessible later via map. It registers a long-click listener on the map that calls promptForMessage with the pressed location, enables the map’s My Location widgets if the app has location permissions, and calls syncCircles 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()
    }
    
  6. Write method hideMessage that accepts a string message and a LatLng location. It tracks the hidden message in hiddenMessage, 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()
    }
    
  7. 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)
          })
        }
      }
    }
    
  8. Write method saveHiddenMessage, which writes out in JSON the current hidden message to hidden.json, a private file on internal storage. Use Gson to produce the JSON. Gson knows how to serialize HiddenMessage 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())
      }
    }
    
  9. Write method promptForMessage that accepts a LatLng location parameter. It prompts the user via an AlertDialog to enter a string. When the user positively dismisses the dialog, it calls hideMessage.
    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()
      }
    }
    
  10. Write method clearHiddenMessage that removes all traces of any currently hidden message, deleting the file saved earlier by saveHiddenMessage, 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()
    }
    
  11. Write method loadHiddenMessage that loads into hiddenMessage any hidden message previously saved by saveHiddenMessage. 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()
    }
    
  12. 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. Call clearHiddenMessage 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()
        }
      }
    }
    
  13. Write method unlockMessageFailure that accepts a Double 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()
      }
    }
    
  14. Write method attemptUnlock that accepts a LatLng 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, call unlockMessageSuccess. Otherwise, call unlockMessageFailure.
    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)
        }
      }
    }
    
  15. Write method queryLocationForUnlock that creates and registers a LocationListener. 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.

  1. Write class Endpoint, which accepts as parameters a string id and a DiscoveredEndpointInfo. Its public interface should expose id, name, and toString. Both name and toString should yield the endpoint’s name—which is not the same as id.
  2. In HostActivity, define field payloadListener as a PayloadCallback to receive a MIDI message as an array of bytes. Issue the message with KeyboardActivity.sendMidiMessage.
  3. In HostActivity, define field connectionListener as a ConnectionLifecycleCallback. 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.
  4. In HostActivity, define method advertise to start advertising the device as a “server.” The user should know the status of the advertising. Communicate events using toasts.
  5. In JoinActivity, define field payloadListener as a PayloadCallback to do absolutely nothing. The server will not be communicating anything back to the client.
  6. In JoinActivity, define field connectionListener as a ConnectionLifecycleCallback. 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 the hostEndpoint field.
  7. In JoinActivity, define method joinHost, which accepts an Endpoint. Request a connection to the specified host endpoint. The user should know the status of this request. Communicate events using toasts.
  8. In JoinActivity, define method showEndpointChooser, which accepts a list of Endpoint and returns an ArrayAdapter<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 an AlertDialog. When an endpoint from the adapter is clicked on, join it. When the dialog is dismissed, stop discovering new hosts.
  9. In JoinActivity, define method createDiscoverListener, which accepts a MutableList of Endpoint and an ArrayAdapter of Endpoint. It returns an EndpointDiscoveryCallback that adds newly found endpoints to the list and removes lost endpoints from the list, notifying the adapter of any changes.
  10. In JoinActivity, define method discover. It creates an empty MutableList of Endpoint, 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.
  11. In JoinActivity, override sendMidiMessage 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!

Sincerely,

P.S. It’s time for a haiku!

Groomed path suggester
Geographic pin sticker
Good parking spotter

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *