teaching machines

SENG 440: Lecture 17 – Gravity Sensor

Dear students,

Today we write Lonely Phone, an app that senses when the phone is laid flat and starts ringing to grab the user’s attention. This will be the first time we use the code you wrote outside of class and submitted through the Crowdsource tool. This makes me slightly nervous. When I control the content, I can ensure that we don’t stray into territory that I know nothing about. When you drag us there, you might see me for what I am: a finite human being.

I welcome feedback on how this goes. My guess is that it will be more messy. I’m okay with looking more foolish if it means you’ve felt a greater sense of ownership and interest in the ideas behind the app.

Next lecture we will discuss locating a device with GPS. See the TODO below for the assigned exercises.

Lonely Phone

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. In MainActivity, declare read-only property serviceIntent that returns an explicit intent for starting up the service.
    private val serviceIntent
      get() = Intent(this, CryService::class.java)
    
  2. In MainActivity, register in onCreate a callback on the switch that starts the service as a foreground service when on, and stops the service when off.
    cryWhenLonelySwitch.setOnCheckedChangeListener { _, isOn ->
      if (isOn) {
        startForegroundService(serviceIntent)
      } else {
        stopService(serviceIntent)
      }
    }
    
  3. In MainActivity, write method createNotificationChannel that creates a notification channel for alarms of high importance.
    private fun createNotificationChannel() {
      val importance = NotificationManager.IMPORTANCE_HIGH
      val channel = NotificationChannel(Notification.CATEGORY_ALARM, "Lonely Phone Notifications", importance).apply {
        description = "Hear the plaintive cry of your phone"
      }
      val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
      notificationManager.createNotificationChannel(channel)
    }
    
  4. Read up on bound services and started services, and be prepared to explain the differences. In CryService, write method onBind such that no client can bind to this service.
    override fun onBind(intent: Intent?) = null
    
  5. In CryService, write method startRinging to start playing the phone’s default ringtone on loop.
    private fun startRinging() {
      val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
      player = MediaPlayer.create(this, ringtoneUri).apply {
        isLooping = true
        start()
      }
    }
    
  6. In CryService, write method stopRinging to stop playing the phone’s default ringtone and release any associated resources.
    private fun stopRinging() {
      player?.apply {
        stop()
        release()
      }
      player = null
    }
    
  7. In CryService, define field gravityListener as a SensorEventListener. When it detects that the phone is flat (either on its screen or back) but it wasn’t flat before, it starts the phone ringing. When it detects the phone is not flat but it was before, it stops the ringing.
    private var isFlat = false
    private val gravityListener = object : SensorEventListener {
      override fun onAccuracyChanged(sensor: Sensor, p1: Int) {
      }
    
      override fun onSensorChanged(event: SensorEvent) {
        val isFlatNow = event.values[2].absoluteValue > 9.5
        if (isFlat != isFlatNow) {
          isFlat = isFlatNow
          if (isFlat) {
            startRinging()
          } else {
            stopRinging()
          }
        }
      }
    }
    
  8. Investigate foreground services and background services, and be prepared to explain their differences and the reason we need a foreground service. In CryService, write method becomeForegroundService that makes this service a foreground service.
    private fun becomeForegroundService() {
      val intent = Intent(this, MainActivity::class.java).let {
        PendingIntent.getActivity(this, 0, it, 0)
      }
    
      val notification = Notification.Builder(this, Notification.CATEGORY_ALARM).run {
        setSmallIcon(R.drawable.baby)
        setContentTitle("Lonely Phone")
        setContentText("Don't let me down.")
        setContentIntent(intent)
        setAutoCancel(true)
        build()
      }
    
      startForeground(1, notification)
    }
    
  9. In CryService, write method onStartCommand that turns this service into a foreground service and registers our gravity sensor listener. Investigate the possible return values.
    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
      becomeForegroundService()
    
      sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
      sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY).let { sensor ->
        sensorManager.registerListener(gravityListener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
      }
    
      return START_STICKY
    }
    
  10. In CryService, write method onDestroy that unregisters the gravity sensor listener and stops any ringing.
    override fun onDestroy() {
      super.onDestroy()
      sensorManager.unregisterListener(gravityListener)
      stopRinging()
    }
    
  11. When the MainActivity first starts up, we need to set the initial state of the switch to on or off. If the service is running, we want it on. But there’s no builtin way to query whether the service is running. Instead, we can set up a within-app broadcast. In MainActivity, add code to onCreate that creates a local broadcast manager. Register a new BroadcastReceiver responds only to action "pong". In its onReceive, toggle the switch on. Then send a synchronous broadcast of an Intent whose action is "ping".
    LocalBroadcastManager.getInstance(this).let { broadcastManager ->
      val pongReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
          cryWhenLonelySwitch.isChecked = true
        }
      }
      broadcastManager.registerReceiver(pongReceiver, IntentFilter("pong"))
      broadcastManager.sendBroadcastSync(Intent("ping"))
    }
    
  12. In CryService, write method onCreate to create a local broadcast manager. Register a new BroadcastReceiver that responds only to action "ping". In its onReceive method send a synchronous broadcast of an Intent whose action is "pong".
    override fun onCreate() {
      super.onCreate()
      LocalBroadcastManager.getInstance(this).let { broadcastManager ->
        val pingReceiver = object : BroadcastReceiver() {
          override fun onReceive(context: Context, intent: Intent) {
            broadcastManager.sendBroadcastSync(Intent("pong"))
          }
        }
        broadcastManager.registerReceiver(pingReceiver, IntentFilter("ping"))
      }
    }
    

To test this app, I have set my ringtone using the Rattler app that we wrote earlier in the semester to this tune at 100 beats per minute.

4g#5 4f#5 4e5 2f#5. 1p 4g#5 4g#5 4g#5 8e5. 16c#5 2h4 1p 4g#5 4f#5 4e5 8f#5. 16e5 2c#5 1p 4g#5 4g#5 4g#5 8e5. 16c#5 2h4

TODO

Our next app is called Hideaway, which supports a two-player geo-located “game” in which the players hide virtual messages in physical locations. The UI presents a map and an unlock button in the action bar.

Player A navigates to a location on the map, long-presses, and hides a message that can only be unlocked at the pressed location. The device is given to player B.

Player B, with the device in hand, physically moves to a location on the globe, presses the unlock button, and is told how far away is the hidden message from the current location. A circle appears on the map around the location. Its radius reflects the distance to the hidden message, meaning the message is hidden somewhere on its perimeter. After several unlock attempts, player B should be able to determine where the message is hidden. When the distance is less than some threshold value, the message is unlocked.

The main activity will start off with this code. The most important details for you are the three instance variables:

class MainActivity : PermittedActivity(), OnMapReadyCallback {
  private lateinit var map: GoogleMap
  private var hiddenMessage: HiddenMessage? = null
  private var unlockMenuItem: MenuItem? = null

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    val mapFragment = supportFragmentManager.findFragmentById(R.id.map) as SupportMapFragment
    mapFragment.getMapAsync(this)
    loadHiddenMessage()

    val permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
    requestPermissions(permissions, 100, {
      promptForGPS()
    }, {
      Toast.makeText(this, "GPS not permitted. You will not be able to unlock hiddenMessage messages.", Toast.LENGTH_LONG).show()
    })
  }

  override fun onStop() {
    super.onStop()
    hiddenMessage?.let {
      saveHiddenMessage()
    }
  }

  override fun onCreateOptionsMenu(menu: Menu): Boolean {
    menuInflater.inflate(R.menu.menu_actionbar, menu)
    unlockMenuItem = menu.findItem(R.id.unlockButton)
    syncUnlockButton()
    return true
  }

  override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
    R.id.unlockButton -> {
      queryLocationForUnlock()
      true
    }
    R.id.clearHiddenButton -> {
      clearHiddenMessage()
      true
    }
    else -> super.onOptionsItemSelected(item)
  }
}

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. I completed all but one in less than 10 lines of code. All but the first exercise will appear in MainActivity.

  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.
  2. Write getter-only private property hasLocationPermissions that returns true when the app has been granted the ACCESS_FINE_LOCATION permission.
  3. Write method syncUnlockButton to make the unlockMenuItem visible if and only if there’s a hidden message.
  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.
  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.
  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.
  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.
  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.
  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.
  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.
  11. Write method loadHiddenMessage that loads into hiddenMessage any hidden message previously saved by saveHiddenMessage. Use Gson.
  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.
  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.
  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.
  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.

See you next time!

Sincerely,

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

“That man has the flu”
Thanks, Siri, I’ll sit elsewhere
“By that Trump voter?”

Comments

Leave a Reply

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