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.
- In
MainActivity
, declare read-only propertyserviceIntent
that returns an explicit intent for starting up the service.private val serviceIntent get() = Intent(this, CryService::class.java)
- In
MainActivity
, register inonCreate
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) } }
- In
MainActivity
, write methodcreateNotificationChannel
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) }
- Read up on bound services and started services, and be prepared to explain the differences. In
CryService
, write methodonBind
such that no client can bind to this service.override fun onBind(intent: Intent?) = null
- In
CryService
, write methodstartRinging
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() } }
- In
CryService
, write methodstopRinging
to stop playing the phone’s default ringtone and release any associated resources.private fun stopRinging() { player?.apply { stop() release() } player = null }
- In
CryService
, define fieldgravityListener
as aSensorEventListener
. 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() } } } }
- Investigate foreground services and background services, and be prepared to explain their differences and the reason we need a foreground service. In
CryService
, write methodbecomeForegroundService
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) }
- In
CryService
, write methodonStartCommand
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 }
- In
CryService
, write methodonDestroy
that unregisters the gravity sensor listener and stops any ringing.override fun onDestroy() { super.onDestroy() sensorManager.unregisterListener(gravityListener) stopRinging() }
- 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. InMainActivity
, add code toonCreate
that creates a local broadcast manager. Register a newBroadcastReceiver
responds only to action"pong"
. In itsonReceive
, toggle the switch on. Then send a synchronous broadcast of anIntent
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")) }
- In
CryService
, write methodonCreate
to create a local broadcast manager. Register a newBroadcastReceiver
that responds only to action"ping"
. In itsonReceive
method send a synchronous broadcast of anIntent
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
.
- 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. - Write getter-only private property
hasLocationPermissions
that returns true when the app has been granted theACCESS_FINE_LOCATION
permission. - Write method
syncUnlockButton
to make theunlockMenuItem
visible if and only if there’s a hidden message. - 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. - 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. - 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. - 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. - 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. - 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
. - 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. - Write method
loadHiddenMessage
that loads intohiddenMessage
any hidden message previously saved bysaveHiddenMessage
. Use Gson. - 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. - 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. - 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
. - 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.
See you next time!
P.S. It’s time for a haiku!
“That man has the flu”
Thanks, Siri, I’ll sit elsewhere
“By that Trump voter?”