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.
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.
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)
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)
}
}
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)
}
CryService
, write method onBind
such that no client can bind to this service. override fun onBind(intent: Intent?) = null
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()
}
}
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
}
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()
}
}
}
}
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)
}
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
}
CryService
, write method onDestroy
that unregisters the gravity sensor listener and stops any ringing. override fun onDestroy() {
super.onDestroy()
sensorManager.unregisterListener(gravityListener)
stopRinging()
}
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"))
}
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
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
.
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.hasLocationPermissions
that returns true when the app has been granted the ACCESS_FINE_LOCATION
permission.syncUnlockButton
to make the unlockMenuItem
visible if and only if there’s a hidden message.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.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.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.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.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.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
.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.loadHiddenMessage
that loads into hiddenMessage
any hidden message previously saved by saveHiddenMessage
. Use Gson.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.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.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
.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!
P.S. It’s time for a haiku!
“That man has the flu”
Thanks, Siri, I’ll sit elsewhere
“By that Trump voter?”
Comments