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:

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:

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

What you'll need

What you should already know

What you'll learn

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.

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:

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
}
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

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 (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
}
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()
   }
}
  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))
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()
}
  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()
       }
   }
}
   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()
   }
}
  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.

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)
                   }
               }
           }
       }
   }
}
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)
   }
}
  1. Run your app. Your screen should display a clue, and a toast that tells you that the geofence has been added.

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
           )
       }
   }
}
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) {}
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
)
  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.

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.

  1. Select Location.

  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.

  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))
       }
   }
}
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))
}
  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!

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:

Udacity courses:

Android developer documentation:

Other resources:

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