Advanced Android in Kotlin 04.2: Adding geofencing to your map

This codelab is part of the Advanced Android in Kotlin course. You'll get the most value out of this course if you work through the codelabs in sequence, but it is not mandatory. All the course codelabs are listed on the Advanced Android in Kotlin codelabs landing page.

Introduction

Welcome to the Advanced Android in Kotlin lesson on geofences!

The geofencing API allows you to define perimeters, also referred to as geofences, which surround areas of interest. Your app gets a notification when the device crosses a geofence, which allows you to provide a relevant experience when users are inside the "fenced" area.

For example, an airline app can define a geofence around an airport when a flight reservation is near boarding time. When the device crosses the geofence, the app can send a notification that takes users to an activity that allows them to get their boarding pass.

The Geofencing API uses device sensors to accurately detect the location of the device in a battery-efficient way. The device can be in one of three states, or transition types, related to the geofence.

Geofence transition types:

59e23727ef7894bb.png

Enter: Indicates that the device has entered the geofence(s).

Dwell: Indicates that the device has entered and is dwelling inside the geofence(s) for a given period of time.

Exit: Indicates that the device has exited the geofence(s).

Geofencing has many applications including:

  • Reminder apps, where you can get a reminder when you close in on a destination. For example, you get a reminder to pick up a prescription when you get close to your pharmacy.
  • Child location services, where a parent can be notified if a child leaves an area designated by a geofence.
  • Attendance recording, where an employer can know when their employees arrive by the time they enter a geofence.
  • A treasure hunt app that uses geofences to mark the place where a treasure is hidden. When you enter that perimeter, you will be notified that you have won.–-This is the app you will be building in this codelab!

The image below shows geofence locations denoted by markers and the radiuses around them.

925e9bf5b02ac803.png

What you'll need

  • The latest version of Android Studio.
  • A minimum of SDK API 29 on your device or emulator. (The app should still work on lower API levels, but may look different.)

What you should already know

What you'll learn

  • How to check user permissions.
  • How to check device settings.
  • How to add Broadcast Receivers.
  • How to add geofences.
  • How to handle geofence transitions.
  • How to mock locations in the emulator.

The app you will create in this codelab is a Treasure Hunt game. This app is a scavenger hunt that gives the user a clue, and when the user enters the correct location, the app will prompt them with the next clue, or a win screen if they have finished the hunt.

The screenshots below show a clue and the win screen.

Note that the current game code has San Francisco locations hardcoded, but you will learn how to customize the game by creating your own geofences to lead people to places in your area.

To get started, download the code:

Download Zip

Alternatively, you can clone the GitHub repository for the code and switch to the starter-code branch:

$ git clone https://github.com/googlecodelabs/android-kotlin-geo-fences

Step 1: Run the starter app

  1. Run the starter app on an emulator or on your own device. You should see a splash screen with an Android holding a treasure map.

22d43f4860b58ac4.png

Step 2: Familiarize yourself with the code

The starter app contains code to help you get started and save you some work. It contains assets, layouts, an activity, a view model, and a broadcast receiver that you will complete during this lesson.

Open the following important classes provided for you, and familiarize yourself with the code:

  • HuntMainActivity.kt is the primary class you will be working in. This class contains skeleton code for functions that handle permissions, and for adding and removing geofences.
  • GeofenceViewModel.kt is the ViewModel associated with HuntMainActivity.kt. This class handles the GeofenceIndex LiveData and determines which hint should be shown on the screen.
  • NotificationUtils.kt: When you enter a geofence, a notification pops up. This class creates and styles that notification.
  • activity_main.xml currently displays an image of an Android, but you will implement it to display a hint to lead your players to the next location.
  • GeofenceBroadcastReceiver.kt contains skeleton code for the onReceive() method of the BroadcastReceiver. You will update the onReceive() method in this codelab.

The first thing your app needs to do is get location permissions from the user. This involves the following high-level steps, and will be the same for any app you create that needs permissions.

  1. Add the permissions to the Android manifest.
  2. Create a method that checks for permissions.
  3. Request those permissions by calling that method.
  4. Handle the result of asking the user for the permissions.

Step 1: Add permissions to the AndroidManifest

The Geofencing API requires that location be shared at all times. If you are on Android version Q or later, you will need to specifically ask the user for this permission.

  1. Open AndroidManifest.xml.
  2. Add permissions for ACCESS_FINE_LOCATION and ACCESS_BACKGROUND_LOCATION above the application tag.
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

Step 2: Check if the device is running Android Q (API 29) or later

Check whether the device is running Android Q or later. For devices running Android Q (API 29) or later, you will have to ask for an additional background location permission.

  1. Open HuntMainActivity.kt.
  2. Above the onCreate() method, add a member variable a called runningQOrLater. This will check what API the device is running.
private val runningQOrLater = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q

Step 3: Create a method to check for permissions

In your app, you need to check if permissions have been granted, and if not, ask for them.

  1. In HuntMainActivity, replace the code in the foregroundAndBackgroundLocationPermissionApproved() method with the code below, which is explained afterwards.
@TargetApi(29)
private fun foregroundAndBackgroundLocationPermissionApproved(): Boolean {
   val foregroundLocationApproved = (
           PackageManager.PERMISSION_GRANTED ==
           ActivityCompat.checkSelfPermission(this,
               Manifest.permission.ACCESS_FINE_LOCATION))
   val backgroundPermissionApproved =
       if (runningQOrLater) {
           PackageManager.PERMISSION_GRANTED ==
           ActivityCompat.checkSelfPermission(
               this, Manifest.permission.ACCESS_BACKGROUND_LOCATION
           )
       } else {
           true
       }
   return foregroundLocationApproved && backgroundPermissionApproved
}
  • First, you must check if the ACCESS_FINE_LOCATION permission has been granted.
val foregroundLocationApproved = (
           PackageManager.PERMISSION_GRANTED ==
           ActivityCompat.checkSelfPermission(this,
               Manifest.permission.ACCESS_FINE_LOCATION))
  • If the device is running Android Q (API 29) or higher, check that the ACCESS_BACKGROUND_LOCATION permission has been granted. Return true if the device is running a version lower than Q, where you don't need a permission to access location in the background.
val backgroundPermissionApproved =
   if (runningQOrLater) {
       PackageManager.PERMISSION_GRANTED ==
       ActivityCompat.checkSelfPermission(
           this, Manifest.permission.ACCESS_BACKGROUND_LOCATION
       )
   } else {
       true
   }
  • Return true if the permissions have been granted, and false if not.
return foregroundLocationApproved && backgroundPermissionApproved

Step 4: Request permissions

  1. Copy the following code into the requestForegroundAndBackgroundLocationPermissions() method. This is where you ask the user to grant location permissions. Each step is explained in the bullet points below.
@TargetApi(29 )
private fun requestForegroundAndBackgroundLocationPermissions() {
   if (foregroundAndBackgroundLocationPermissionApproved())
       return
   var permissionsArray = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
   val resultCode = when {
       runningQOrLater -> {
           permissionsArray += Manifest.permission.ACCESS_BACKGROUND_LOCATION
           REQUEST_FOREGROUND_AND_BACKGROUND_PERMISSION_RESULT_CODE
       }
       else -> REQUEST_FOREGROUND_ONLY_PERMISSIONS_REQUEST_CODE
   }
   Log.d(TAG, "Request foreground only location permission")
   ActivityCompat.requestPermissions(
       this@HuntMainActivity,
       permissionsArray,
       resultCode
   )
}
  • If the permissions have already been granted, you don't need to ask again, so you can return out of the method.
if (foregroundAndBackgroundLocationPermissionApproved())
   return
  • The permissionsArray contains the permissions to be requested. Initially, add ACCESS_FINE_LOCATION since that is needed by all API levels.
var permissionsArray = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
  • Next, you need a resultCode. This code is different if the device is running Q (API 29) or later and determines whether you need to check for one permission (fine location) or multiple permissions (fine and background location) when the user returns from the permission request screen.
  • Add a when statement to check the version running, and assign resultCode to REQUEST_FOREGROUND_AND_BACKGROUND_PERMISSION_RESULT_CODE if the device is running Q (API 29) or later, and REQUEST_FOREGROUND_ONLY_PERMISSIONS_REQUEST_CODE, if not.
val resultCode = when {
   runningQOrLater -> {
       permissionsArray += Manifest.permission.ACCESS_BACKGROUND_LOCATION
       REQUEST_FOREGROUND_AND_BACKGROUND_PERMISSION_RESULT_CODE
   }
   else -> REQUEST_FOREGROUND_ONLY_PERMISSIONS_REQUEST_CODE
}
  • Finally, request permissions passing in the current activity, the permissions array, and the result code.
ActivityCompat.requestPermissions(
   this@HuntMainActivity,
   permissionsArray,
   resultCode
)

Step 5: Handle permissions

Once the user responds to the permissions request, you need to handle their response in onRequestPermissionsResult().

  1. Copy this code into the onRequestPermissionsResult() method.
override fun onRequestPermissionsResult(
   requestCode: Int,
   permissions: Array<String>,
   grantResults: IntArray
) {
   Log.d(TAG, "onRequestPermissionResult")

   if (
       grantResults.isEmpty() ||
       grantResults[LOCATION_PERMISSION_INDEX] == PackageManager.PERMISSION_DENIED ||
       (requestCode == REQUEST_FOREGROUND_AND_BACKGROUND_PERMISSION_RESULT_CODE &&
               grantResults[BACKGROUND_LOCATION_PERMISSION_INDEX] ==
               PackageManager.PERMISSION_DENIED))
   {
       Snackbar.make(
           binding.activityMapsMain,
           R.string.permission_denied_explanation, 
           Snackbar.LENGTH_INDEFINITE
       )
           .setAction(R.string.settings) {
               startActivity(Intent().apply {
                   action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
                   data = Uri.fromParts("package", BuildConfig.APPLICATION_ID, null)
                   flags = Intent.FLAG_ACTIVITY_NEW_TASK
               })
           }.show()
   } else {
       checkDeviceLocationSettingsAndStartGeofence()
   }
}
  • Permissions can be denied in a few ways:
  1. If the grantResults array is empty, then the interaction was interrupted and the permission request was cancelled.
  2. If the grantResults array's value at the LOCATION_PERMISSION_INDEX has a PERMISSION_DENIED, it means that the user denied foreground permissions.
  3. If the request code equals REQUEST_FOREGROUND_AND_BACKGROUND_PERMISSION_RESULT_CODE and the BACKGROUND_LOCATION_PERMISSION_INDEX is denied, it means that the device is running Q (API 29) or above and that background permissions were denied.
if (grantResults.isEmpty() ||
   grantResults[LOCATION_PERMISSION_INDEX] == PackageManager.PERMISSION_DENIED ||
   (requestCode == REQUEST_FOREGROUND_AND_BACKGROUND_PERMISSION_RESULT_CODE &&
           grantResults[BACKGROUND_LOCATION_PERMISSION_INDEX] ==
           PackageManager.PERMISSION_DENIED))
  • This app has very little use if permissions are not granted, so present a snackbar explaining to the user that the app needs location permissions in order for them to be able to play.
Snackbar.make(
   binding.activityMapsMain,
   R.string.permission_denied_explanation,
   Snackbar.LENGTH_INDEFINITE
)
   .setAction(R.string.settings) {
       startActivity(Intent().apply {
           action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
           data = Uri.fromParts("package", BuildConfig.APPLICATION_ID, null)
           flags = Intent.FLAG_ACTIVITY_NEW_TASK
       })
   }.show()
  • Otherwise, permissions have been granted and you can call the checkDeviceLocationSettingsAndStartGeofence() method.
else {
   checkDeviceLocationSettingsAndStartGeofence()
}
  1. Run your app! You should see a pop-up prompting you to grant permissions. Choose Allow all the time, or Allow if you are running an API lower than 29.

Your code now asks the user to give permissions.

However, if the user's device location is turned off, then that permission won't mean anything.

The next thing to check is if the device's location is on. In this step, you will add code to check that a user has their device location enabled, and if not, display an activity where they can turn it on using a location request.

  1. Copy this code into the checkDeviceLocationSettingsAndStartGeofence() method in HuntMainActivity.kt. The steps are explained in the bullet points below.
private fun checkDeviceLocationSettingsAndStartGeofence(resolve:Boolean = true) {
   val locationRequest = LocationRequest.create().apply {
       priority = LocationRequest.PRIORITY_LOW_POWER
   }
   val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
   val settingsClient = LocationServices.getSettingsClient(this)
   val locationSettingsResponseTask =
       settingsClient.checkLocationSettings(builder.build())
   locationSettingsResponseTask.addOnFailureListener { exception ->
       if (exception is ResolvableApiException && resolve){
           try {
               exception.startResolutionForResult(this@HuntMainActivity,
                   REQUEST_TURN_DEVICE_LOCATION_ON)
           } catch (sendEx: IntentSender.SendIntentException) {
               Log.d(TAG, "Error getting location settings resolution: " + sendEx.message)
           }
       } else {
           Snackbar.make(
               binding.activityMapsMain,
               R.string.location_required_error, Snackbar.LENGTH_INDEFINITE
           ).setAction(android.R.string.ok) {
               checkDeviceLocationSettingsAndStartGeofence()
           }.show()
       }
   }
   locationSettingsResponseTask.addOnCompleteListener {
       if ( it.isSuccessful ) {
           addGeofenceForClue()
       }
   }
}
  • First, create a LocationRequest and use it with a LocationSettingsRequest Builder.
   val locationRequest = LocationRequest.create().apply {
       priority = LocationRequest.PRIORITY_LOW_POWER
   }
   val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
  • Next, use LocationServices to get the SettingsClient. Create a val called locationSettingsResponseTask and use it to check the location settings.
val settingsClient = LocationServices.getSettingsClient(this)
val locationSettingsResponseTask =
   settingsClient.checkLocationSettings(builder.build())
  • Since the case you are most interested in is finding out if the location settings are not satisfied, add an onFailureListener() to the locationSettingsResponseTask.
locationSettingsResponseTask.addOnFailureListener { exception ->
}
  • Check if the exception is of type ResolvableApiException, and if so, try calling the startResolutionForResult() method in order to prompt the user to turn on device location.
if (exception is ResolvableApiException && resolve){
   try {
       exception.startResolutionForResult(this@HuntMainActivity,
           REQUEST_TURN_DEVICE_LOCATION_ON)
   }
  • If calling startResolutionForResult enters the catch block, print a log.
catch (sendEx: IntentSender.SendIntentException) {
   Log.d(TAG, "Error getting location settings resolution: " + sendEx.message)
}
  • If the exception is not of type ResolvableApiException, present a snackbar that alerts the user that location needs to be enabled to play the treasure hunt.
else {
   Snackbar.make(
       binding.activityMapsMain,
       R.string.location_required_error, Snackbar.LENGTH_INDEFINITE
   ).setAction(android.R.string.ok) {
       checkDeviceLocationSettingsAndStartGeofence()
   }.show()
}
  • If the locationSettingsResponseTask does complete, check that it is successful, and add a geofence for a clue.
locationSettingsResponseTask.addOnCompleteListener {
   if ( it.isSuccessful ) {
       addGeofenceForClue()
   }
}
  1. In onActivityResult(), replace the existing code with the code below. After the user chooses whether to accept or deny device location permissions, this checks if the user has chosen to accept the permissions. If not, ask again.
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
   super.onActivityResult(requestCode, resultCode, data)
   if (requestCode == REQUEST_TURN_DEVICE_LOCATION_ON) {
       checkDeviceLocationSettingsAndStartGeofence(false)
   }
}
  1. To test this, turn off your device location and run the app. You should see a pop-up as shown below. Press OK.

5ab68f94789c78ed.png

Now that you are done checking that the appropriate permissions are granted, add some geofences!

Step 1: Create a Pending Intent

You need a way to handle geofence transitions, which is done with a PendingIntent. A PendingIntent is a description of an Intent, and a target action to perform with it. You will create a pending intent for a BroadcastReceiver to handle the geofence transitions.

  1. In HuntMainActivity.kt, above onCreate(), add a private variable called geofencePendingIntent of type PendingIntent to handle the geofence transitions. Connect geofencePendingIntent to the GeofenceTransitionsBroadcastReceiver.
private val geofencePendingIntent: PendingIntent by lazy {
   val intent = Intent(this, GeofenceBroadcastReceiver::class.java)
   intent.action = ACTION_GEOFENCE_EVENT
   PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}

Step 2: Add a Geofencing Client

A GeofencingClient is the main entry point for interacting with the geofencing APIs.

  1. In the onCreate() method, instantiate the geofencingClient which is already declared in the starter code.
geofencingClient = LocationServices.getGeofencingClient(this)

Step 3: Add geofences

  1. Copy this code into the addGeofenceForClue() method. Each step is explained in the bullet points below..
private fun addGeofenceForClue() {
   if (viewModel.geofenceIsActive()) return
   val currentGeofenceIndex = viewModel.nextGeofenceIndex()
   if(currentGeofenceIndex >= GeofencingConstants.NUM_LANDMARKS) {
       removeGeofences()
       viewModel.geofenceActivated()
       return
   }
   val currentGeofenceData = GeofencingConstants.LANDMARK_DATA[currentGeofenceIndex]

   val geofence = Geofence.Builder()
       .setRequestId(currentGeofenceData.id)
       .setCircularRegion(currentGeofenceData.latLong.latitude,
           currentGeofenceData.latLong.longitude,
           GeofencingConstants.GEOFENCE_RADIUS_IN_METERS
       )
       .setExpirationDuration(GeofencingConstants.GEOFENCE_EXPIRATION_IN_MILLISECONDS)
       .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER)
       .build()

   val geofencingRequest = GeofencingRequest.Builder()
       .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
       .addGeofence(geofence)
       .build()

   geofencingClient.removeGeofences(geofencePendingIntent)?.run {
       addOnCompleteListener {
           geofencingClient.addGeofences(geofencingRequest, geofencePendingIntent)?.run {
               addOnSuccessListener {
                   Toast.makeText(this@HuntMainActivity, R.string.geofences_added,
                       Toast.LENGTH_SHORT)
                       .show()
                   Log.e("Add Geofence", geofence.requestId)
                   viewModel.geofenceActivated()
               }
               addOnFailureListener {
                   Toast.makeText(this@HuntMainActivity, R.string.geofences_not_added,
                       Toast.LENGTH_SHORT).show()
                   if ((it.message != null)) {
                       Log.w(TAG, it.message)
                   }
               }
           }
       }
   }
}
  • First, check if you have any active geofences for your treasure hunt. If you already do, you shouldn't add another since you only want them looking for one treasure at a time.
if (viewModel.geofenceIsActive()) return
  • Find the currentGeofenceIndex from the viewModel. Remove any existing geofences, call geofenceActivated on the viewModel, and return.
val currentGeofenceIndex = viewModel.nextGeofenceIndex()
if(currentGeofenceIndex >= GeofencingConstants.NUM_LANDMARKS){
   removeGeofences()
   viewModel.geofenceActivated()
   return
}
  • Once you have the index of the geofence, and know it is valid, get the data surrounding the geofence, which includes the id, and the latitude and longitude coordinates.
val currentGeofenceData = GeofencingConstants.LANDMARK_DATA[currentGeofenceIndex]
  • Build the geofence using the geofence builder and the information in currentGeofenceData. Set the expiration duration using the constant set in GeofencingConstants. Set the transition type to GEOFENCE_TRANSITION_ENTER. Finally, build the geofence.
val geofence = Geofence.Builder()
   .setRequestId(currentGeofenceData.id)
   .setCircularRegion(currentGeofenceData.latLong.latitude,
       currentGeofenceData.latLong.longitude,
       GeofencingConstants.GEOFENCE_RADIUS_IN_METERS
   )
   .setExpirationDuration(GeofencingConstants.GEOFENCE_EXPIRATION_IN_MILLISECONDS)
   .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER)
   .build()
  • Build the geofence request. Set the initial trigger to INITIAL_TRIGGER_ENTER, add the geofence you just built, and then build.
val geofencingRequest = GeofencingRequest.Builder()
   .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
   .addGeofence(geofence)
   .build()
  • Call removeGeofences() on the geofencingClient to remove any geofences already associated with the PendingIntent.
geofencingClient.removeGeofences(geofencePendingIntent)?.run {
}
  • When removeGeofences() completes, regardless of its success or failure, add the new geofences. You can disregard the success or failure of the removal of geofences because even if the removal fails, it will not affect you adding another geofence.
addOnCompleteListener {
   geofencingClient.addGeofences(geofencingRequest, geofencePendingIntent)?.run {
          }
}
  • If adding the geofences is successful, let the user know with a toast.
addOnSuccessListener {
   Toast.makeText(this@HuntMainActivity, R.string.geofences_added,
       Toast.LENGTH_SHORT)
       .show()
   Log.e("Add Geofence", geofence.requestId)
   viewModel.geofenceActivated()
}
  • If adding the geofences fails, present a different toast, letting the user know that there was an issue with adding the geofences.
addOnFailureListener {
   Toast.makeText(this@HuntMainActivity, R.string.geofences_not_added,
       Toast.LENGTH_SHORT).show()
   if ((it.message != null)) {
       Log.w(TAG, it.message)
   }
}
  1. Run your app. Your screen should display a clue, and a toast that tells you that the geofence has been added.

ee04ee5e1329a1ff.png

Your app now adds geofences. However, try to navigate to the Golden Gate Bridge (the correct location for the default first clue). Nothing happens. Why is that?

When the user enters a geofence established by a clue, in this case the Golden Gate Bridge, you want to be notified, so that you can present the next clue. You can do this by using a Broadcast receiver that can receive details about geofence transition events.

Android apps can send or receive broadcast messages from the Android system and other apps using Broadcast Receivers. They use the publish-subscribe design pattern, where broadcasts are sent out, and apps can register to receive specific broadcasts. When a subscribed broadcast is sent out, the app is notified.

Step 1: Override the onReceive() method

  1. In GeofenceBroadcastReceiver.kt, find the onReceive() function and copy this code into the class. Each step is explained in the bullet points below.
override fun onReceive(context: Context, intent: Intent) {
   if (intent.action == ACTION_GEOFENCE_EVENT) {
       val geofencingEvent = GeofencingEvent.fromIntent(intent)

       if (geofencingEvent.hasError()) {
           val errorMessage = errorMessage(context, geofencingEvent.errorCode)
           Log.e(TAG, errorMessage)
           return
       }

       if (geofencingEvent.geofenceTransition == Geofence.GEOFENCE_TRANSITION_ENTER) {
           Log.v(TAG, context.getString(R.string.geofence_entered))
           val fenceId = when {
               geofencingEvent.triggeringGeofences.isNotEmpty() ->
                   geofencingEvent.triggeringGeofences[0].requestId
               else -> {
                   Log.e(TAG, "No Geofence Trigger Found! Abort mission!")
                   return
               }
           }
           val foundIndex = GeofencingConstants.LANDMARK_DATA.indexOfFirst {
               it.id == fenceId
           }
           if ( -1 == foundIndex ) {
               Log.e(TAG, "Unknown Geofence: Abort Mission")
               return
           }
           val notificationManager = ContextCompat.getSystemService(
               context,
               NotificationManager::class.java
           ) as NotificationManager

           notificationManager.sendGeofenceEnteredNotification(
               context, foundIndex
           )
       }
   }
}
  • A Broadcast Receiver can receive many types of actions. For this app, you only need to know when the geofence has been entered. Check that the intent's action is of type ACTION_GEOFENCE_EVENT.
if (intent.action == ACTION_GEOFENCE_EVENT) {
}
  • Create a variable called geofencingEvent and initialize it to GeofencingEvent with the intent passed in.
val geofencingEvent = GeofencingEvent.fromIntent(intent)
  • If there is an error, you need to understand what went wrong. Save a variable with the error message obtained through the geofences error code. Log that message and return from the method.
if (geofencingEvent.hasError()) {
   val errorMessage = errorMessage(context, geofencingEvent.errorCode)
   Log.e(TAG, errorMessage)
   return
}
  • Check if the geofenceTransition type is ENTER.
if (geofencingEvent.geofenceTransition == Geofence.GEOFENCE_TRANSITION_ENTER) {}
  • If the triggeringGeofences array is not empty, set the fenceID to the first geofence's requestId. You would only have one geofence active at a time, so if the array is non-empty, there would only be one to interact with. If the array is empty, log a message and return.
val fenceId = when {
   geofencingEvent.triggeringGeofences.isNotEmpty() ->
       geofencingEvent.triggeringGeofences[0].requestId
   else -> {
       Log.e(TAG, "No Geofence Trigger Found! Abort mission!")
       return
   }
}
  • Check that the geofence is consistent with the constants listed in GeofenceUtil.kt. If not, print a log and return.
val foundIndex = GeofencingConstants.LANDMARK_DATA.indexOfFirst {
   it.id == fenceId
}

if ( -1 == foundIndex ) {
   Log.e(TAG, "Unknown Geofence: Abort Mission")
   return
}
  • If your code execution has gotten this far, the user has entered a valid geofence. Send a notification telling them the good news!
val notificationManager = ContextCompat.getSystemService(
   context,
   NotificationManager::class.java
) as NotificationManager

notificationManager.sendGeofenceEnteredNotification(
   context, foundIndex
)
  1. Try it yourself by walking into a geofence or emulating your location to be at the geofence (instructions in the next step). When you enter, a notification should pop up.

2a8f4e4be192834e.png

Skip this section if you are not using an emulator.

Since testing this codelab is dependent on walking around, it may be more convenient to use a mocked location on the emulator. In this task, you learn how to mock location on your emulator.

Step 1: Mock your location

  1. On the menu bar next to your emulator, tap the three dots (...) at the bottom to open the Extended controls plane.

b5c85a9fa92550e9.png

  1. Select Location.

9640d326b1e2dc9.png

  1. In the search bar of the map, enter a location, such as the Golden Gate Bridge. The location marker displays at the location you entered.

  1. At the bottom right of the pane, press the Set Location button.

19a5039ce3140d8a.png

  1. Go to the Google Maps app and the notification should pop up. This may take a few seconds.

When you no longer need geofences, it is a best practice to remove them, which stops monitoring, in order to save battery and CPU cycles.

Step 1: Remove geofences

  1. In HuntMainActivity.kt, copy this code into the removeGeofences() method. Each step is explained in the bullet points below.
private fun removeGeofences() {
   if (!foregroundAndBackgroundLocationPermissionApproved()) {
       return
   }
   geofencingClient.removeGeofences(geofencePendingIntent)?.run {
       addOnSuccessListener {
           Log.d(TAG, getString(R.string.geofences_removed))
           Toast.makeText(applicationContext, R.string.geofences_removed, Toast.LENGTH_SHORT)
               .show()
       }
       addOnFailureListener {
           Log.d(TAG, getString(R.string.geofences_not_removed))
       }
   }
}
  • Initially, check if foreground permissions have been approved. If they have not, return.
if (!foregroundAndBackgroundLocationPermissionApproved()) {
       return
   }
  • Call removeGeofences() on the geofencingClient and pass in the geofencePendingIntent.
geofencingClient.removeGeofences(geofencePendingIntent)?.run {
}
  • Add an onSuccessListener(), and inform the user with a toast that the geofences were successfully removed.
addOnSuccessListener {
   Log.d(TAG, getString(R.string.geofences_removed))
   Toast.makeText(applicationContext, R.string.geofences_removed, Toast.LENGTH_SHORT)
       .show()
}
  • Add an onFailureListener() where you log if the geofences weren't removed.
addOnFailureListener {
   Log.d(TAG, getString(R.string.geofences_not_removed))
}
  1. The removeGeofences() method is called in the onDestroy() method included in the starter code.

Now that everything is set up, there is only one thing left to do. Win the game!

Step 1: Win the game!

Navigate to the winning location either by mocking the location on your emulator or physically walking there in person! Congratulations, you won this codelab!

9e3fd90f9c371327.png

You can add landmarks to customize your treasure hunt and add more geofences to make the treasure hunt last longer.

  1. In strings.xml add your custom hint and location.
<!-- Geofence Hints -->
<string name="lombard_street_hint">Go to the most crooked street in the City</string>
<!-- Geofence Locations -->
<string name="lombard_street_location">at Lombard Street</string>
  1. In GeofenceUtils.kt, customize the landmarks by creating a LandmarkDataObject with a destination ID, destination hint, destination location, and destination latitude and longitude. Add this to the LANDMARK_DATA array with your own landmark objects.
val LANDMARK_DATA = arrayOf(
   LandmarkDataObject(
       "Lombard street",
       R.string.lombard_street_hint,
       R.string.lombard_street_location,
       LatLng(37.801205, -122.426752))
)

In this codelab you learned how to:

  • Add permissions, request permissions, check permissions, and handle permissions.
  • Check the device location using the settings client.
  • Add geofences using a pending intent and a geofencing client.
  • Integrate a broadcast receiver to detect when a geofence is entered by overriding the onReceive() method.
  • Remove geofences using the geofencing client.

Udacity courses:

Android developer documentation:

Other resources:

For links to other codelabs in this course, see the Advanced Android in Kotlin codelabs landing page.