Android Geofences

By Paulus, 18 November, 2014
Google has deprecated and removed some of the classes used in this example in favor of their Fuse Location API. An updated example on how to use the new API can be found here.

Introduction

I'm aware that there already exists a geofence tutorial and example on developer.android.com. However, at the time of writing there are two issues that I have with them. The first being that the tutorial's code doesn't match with the example code, which is linked on the tutorial's page. Though the example is a wonderful resource and shows you what to do and how to do it, trying to understand what you need to do can be difficult figure out. 

Requirements

Since we will be using Google Maps and Location based objects, we will need to have our project reference the Google Play Services. See this tutorial on adding the Google Play Services to your project.

Application

The first thing we need to do is add the necessary entries to the manifest file for both the permissions and pieces of our application.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.paulusworld.geofence"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk
        android:minSdkVersion="8"
        android:targetSdkVersion="21" />

    <uses-permission android:name="android.permission.INTERNET" />
	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
	<!--
            Requests address-level location access, which is usually
            necessary for Geofencing
    -->
	<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
	<uses-permission android:name="com.google.android.providers.gsf.permission.READ_GSERVICES" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service android:name="com.paulusworld.geofence.ReceiveTransitionsIntentService" android:exported="false"></service>

        <!--
        	Required for Google Maps
         -->
        <meta-data
            android:name="com.google.android.gms.version"
            android:value="@integer/google_play_services_version" />
        <meta-data
            android:name="com.google.android.maps.v2.API_KEY"
            android:value="ANDROID_MAPS_API_KEY_GOES_HERE" />
    </application>

</manifest>

The android.permission.INTERNET and android.permission.ACCESS_NETWORK_STATE are necessary for downloading Google Map data. The android.permission.ACCESS_FINE_LOCATION and com.google.android.providers.gsf.permission.READ_GSERVICES are needed for use with geofences.

We're specifying a service on line 36. By using a service, our app does not need to be running when we enter, exit, or dwell in a geofence.

I've defined some strings in the strings.xml that will be used in the example:

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <string name="app_name">Geofence</string>
    <string name="hello_world">Hello world!</string>
    <string name="action_settings">Settings</string>

    <string name="geofence_transition_notification_title">
        %1$s geofence(s) %2$s
    </string>
    <string name="geofence_transition_notification_text">
        Click notification to return to app
    </string>

    <string name="geofence_transition_unknown">Unknown transition</string>
    <string name="geofence_transition_entered">Entered</string>
    <string name="geofence_transition_exited">Exited</string>
    <string name="geofence_transition_dwell">Stop dwelling!</string>
</resources>

We'll use a simple layout that uses a MapFragment:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:orientation="vertical"
    tools:context="com.paulusworld.geofence.MainActivity" >

    <fragment
        android:id="@+id/map"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="45"
        class="com.google.android.gms.maps.SupportMapFragment" />

</LinearLayout>

Normally we won't be throwing so much of the location related code in the MainActivity, but for simplicity's sake we will do it this time.  

package com.paulusworld.geofence;

import android.app.PendingIntent;
import android.content.Intent;
import android.graphics.Color;
import android.location.Location;
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
import android.util.Log;
import android.widget.Toast;


import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GooglePlayServicesClient;
import com.google.android.gms.common.GooglePlayServicesUtil;
import com.google.android.gms.location.Geofence;
import com.google.android.gms.location.LocationClient;
import com.google.android.gms.location.LocationListener;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationStatusCodes;
import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.Circle;
import com.google.android.gms.maps.model.CircleOptions;
import com.google.android.gms.maps.model.LatLng;

import java.util.ArrayList;

public class MainActivity extends FragmentActivity
        implements GooglePlayServicesClient.ConnectionCallbacks,
        GooglePlayServicesClient.OnConnectionFailedListener,
        LocationListener,
        LocationClient.OnAddGeofencesResultListener {

    private final static String TAG = "MainActivity";
    /**
     * Google Map object
     */
    private GoogleMap mMap;

    /**
     * Geofence Data
     */

    /**
     * Coordinates for the Geofence.
     */
    private LatLng mGeofenceLatLng = new LatLng(YOUR_LATITUDE, YOUR_LONGITUDE);

    /**
     * Radius of the Geofence in meters.
     */
    private int mRadius = 80;

    /**
     * The Geofence object.
     */
    private Geofence mGeofence;

    /**
     * Entry point for Google's location related APIs.
     */
    private LocationClient mLocationClient;

    /**
     * Used to set the priority and intervals of the location requests.
     */
    private LocationRequest mLocationRequest;

    /**
     * Visuals
     */
    private CircleOptions mCircleOptions;
    private Circle mCircle;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        /**
         * We create a new LocationClient which is used as an entry point for Google's location
         * related APIs. The first parameter is the context, the second is
         * GooglePlayServicesClient.ConnectionCallbacks, and the third is
         * GooglePlayServicesClient.OnConnectionFailedListener. Since we implemented both listeners
         * on the MainActivity class, we pass 'this' for the second and third parameters.
         */
        mLocationClient = new LocationClient(this, this, this);

        /**
         * With the LocationRequest, we can set the quality of service. For example, the priority
         * and intervals.
         */
        mLocationRequest = LocationRequest.create();
        mLocationRequest.setPriority(LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY);
        mLocationRequest.setInterval(3600000);
        mLocationRequest.setFastestInterval(60000);
    }

    @Override
    protected void onStart() {
        super.onStart();
        // Connect to the location APIs.
        mLocationClient.connect();
    }

    protected void onStop() {
        // Disconnect from the location APIs.
        mLocationClient.disconnect();
        super.onStop();
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (GooglePlayServicesUtil.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS) {
            setUpMapIfNeeded();
        } else {
            GooglePlayServicesUtil.getErrorDialog(
                    GooglePlayServicesUtil.isGooglePlayServicesAvailable(this),
                    this, 0);
        }
    }

    private void setUpMapIfNeeded() {
        // Do a null check to confirm that we have not already instantiated the
        // map.
        if (mMap == null) {
            // Try to obtain the map from the SupportMapFragment.
            mMap = ((SupportMapFragment) getSupportFragmentManager()
                    .findFragmentById(R.id.map)).getMap();

            // Check if we were successful in obtaining the map.
            if (mMap != null) {
                setUpMap();
            }
        }
    }

    /**
     * This is where we can add markers or lines, add listeners or move the
     * camera. In this case, we just add a marker near Africa.
     * <p/>
     * This should only be called once and when we are sure that {@link #mMap}
     * is not null.
     */
    private void setUpMap() {
        // Centers the camera over the building and zooms int far enough to
        // show the floor picker.
        mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(
                new LatLng(mGeofenceLatLng.latitude, mGeofenceLatLng.longitude), 18));
        // Hide labels.
        mMap.setMapType(GoogleMap.MAP_TYPE_HYBRID);
        mMap.setIndoorEnabled(false);
        mMap.setMyLocationEnabled(true);

        // Adding visuals.
        mCircleOptions = new CircleOptions()
                .center(mGeofenceLatLng).radius(mRadius).fillColor(0x40ff0000)
                .strokeColor(Color.TRANSPARENT).strokeWidth(2);
        mCircle = mMap.addCircle(mCircleOptions);

    }

    @Override
    public void onConnectionFailed(ConnectionResult connectionResult) {
        Log.v("GEOFENCE", "Connection to LocationClient failed!");
    }

    @Override
    public void onConnected(Bundle arg0) {

        Log.v("GEOFENCE", "Connected to location services.");

        ArrayList<Geofence> geofences = new ArrayList<Geofence>();

        /**
         * The addGeofences function requires that the Geofences be in a List, so there can be
         * multiple geofences. For this example we will only need one.
         */
        mGeofence = new Geofence.Builder()
                .setRequestId("Geofence")
                // There are three types of Transitions.
                .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_DWELL | Geofence.GEOFENCE_TRANSITION_ENTER | Geofence.GEOFENCE_TRANSITION_EXIT)
                // Set the geofence location and radius.
                .setCircularRegion(mGeofenceLatLng.latitude, mGeofenceLatLng.longitude, mRadius)
                // How long the geofence will remain in place.
                .setExpirationDuration((1000 * 60) * 60)
                // This is required if you specify GEOFENCE_TRANSITION_DWELL when setting the transition types.
                .setLoiteringDelay(1000)
                .build();

        /**
         * Adding the geofence to the ArrayList, which will be passed as the first parameter
         * to the LocationClient object's addGeofences function.
         */
        geofences.add(mGeofence);

        /**
         * We're creating a PendingIntent that references the ReceiveTransitionsIntentService class
         * in conjunction with the geofences.
         */
        Intent intent = new Intent(this, ReceiveTransitionsIntentService.class);
        PendingIntent pendingIntent = PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);

        /**
         * We want this (MainActivity) to handle location updates.(see onLocationChanged function)
         */
        mLocationClient.requestLocationUpdates(mLocationRequest, this);
        /**
         * Adding the Geofences and PendingIntent to the LocationClient and setting this
         * (MainActivity) to handle onAddGeofencesResult. The pending intent, which is the
         * ReceiveTransitionsIntentService, is what gets utilized when one of the transitions
         * that was specified in the geofence is fired.
         */
        mLocationClient.addGeofences(geofences, pendingIntent, this);

    }

    @Override
    public void onDisconnected() {
        Log.v("GEOFENCE", "Disconnected");
    }

    @Override
    public void onLocationChanged(Location location) {
        /**
         * Location data is passed back to this function.
         */
        Toast.makeText(this, "Location Changed: " + location.getLatitude() + ", " + location.getLongitude(), Toast.LENGTH_LONG).show();
    }

    @Override
    public void onAddGeofencesResult(int statusCode, String[] geofenceRequestIds) {

        switch(statusCode) {
            case LocationStatusCodes.SUCCESS:
                Log.v(TAG, "Successfully added Geofence.");
                break;
            case LocationStatusCodes.ERROR:
                Log.v(TAG, "Error adding Geofence.");
                break;
            case LocationStatusCodes.GEOFENCE_TOO_MANY_GEOFENCES:
                Log.v(TAG, "Too many geofences.");
                break;
            case LocationStatusCodes.GEOFENCE_TOO_MANY_PENDING_INTENTS:
                Log.v(TAG, "Too many pending intents.");
                break;
        }
    }
}

The MainActivity class implements the the GooglePlayServicesClient.ConnectionCallbacks, GooglePlayServicesClient.OnConnectionFailedListener, LocationListener, and LocatoinClient.OnAddGeofencesResultListener.

The GooglePlayServicesClient.ConnectionCallbacks requires the class to implement the following functions:

The GooglePlayServicesClient.OnConnectionFailedListener requires the class to implement the onConnectionFailed(ConnectionResult result) function.

The LocationListener has four functions that can be implemented, but only one of them is required:

Finally, the LocatoinClient.onAddGeofencesResultListener requires the onAddGeofencesResult function to be implemented. This function is called when the addGeofences(List, PendingIntent, OnAddGeofencesResultListener) operation completes, whether successfully or not.

On lines 51 and 56 you can specify your own latitude, longitude, and radius of the geofence. For the most part, if you've seen my previous blog entries regarding Google Maps, there is a lot of code that is reused. 

On line 91 we're creating a LocationClient object that is saying to "Hey, I want to use Google's location services."

On lines 97 to 100, we're creating a LocationRequest object that sets the various parameters that will be applied for requesting location updates. This LocationRequest object is passed in the LocationClient objects function requestLocationUpdates.

On line 160 is simply adding visuals to the map to make it easier to see where the geofence is.

Line 169 is overriding an abstract function that is defined by implementing the OnConnectionFailedListener.

Line 174 is overriding another abstract function that is defined in GooglePlayServicesClient.ConnectionCallbacks. In this function, we're creating the geofence that we want to monitor. On line 178 we're creating an ArrayList to hold the geofences, even though there is only one. We're doing this because when we call the function that adds the geofences, LocationClient.addGeofences it expects an ArrayList of Geofence objects. Geofence objects are built, not instantiated like most other objects. We do this on line 184 using the Builder class and setting all the parameters through other functions from lines 185 to 193 until finally returning a Geofence object by calling the build() function. The Geofence is added to the ArrayLsit on line 200. On line 206 - 207, we're creating a PendingIntent, which is tied to an IntentService and is fired when the conditions set on the Geofence have been met. In this case, enter, dwell, and exit. In order for the PendingIntents to be handled, we need to add the Geofences to the LocationClient.

Line 224 overrides the abstract function onDisconnect that is defined in GooglePlayServicesClient.ConnectionCallbacks.

Line 229 overrides the abstract function onLocationChanged that is defined in LocationSource.OnLocationChangedListener.

The onAddGeofencesResult function found on line 237 is defined in the LocationClient.OnAddGeofencesResultListener. In this function, we would do any work that needs to be done in the event that a specific statusCode is returned.

package com.paulusworld.geofence;

import android.app.IntentService;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.TaskStackBuilder;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;

import com.google.android.gms.location.Geofence;
import com.google.android.gms.location.LocationClient;

import java.util.List;

public class ReceiveTransitionsIntentService extends IntentService {

    private final static String TAG = ReceiveTransitionsIntentService.class.getPackage() + "." + ReceiveTransitionsIntentService.class.getSimpleName();

	public ReceiveTransitionsIntentService() {
		super("ReceiveTransitionsIntentService");
        Log.v(TAG, "Service Constructor");
	}

	@Override
	protected void onHandleIntent(Intent intent) {

        if(!LocationClient.hasError(intent)) {
            int transition = LocationClient.getGeofenceTransition(intent);
            Log.v(TAG, "Transition: " + transition);

            // Post a notification
            List<Geofence> geofences = LocationClient.getTriggeringGeofences(intent);
            String[] geofenceIds = new String[geofences.size()];
            for (int index = 0; index < geofences.size() ; index++) {
                geofenceIds[index] = geofences.get(index).getRequestId();
            }
            String ids = TextUtils.join(", ", geofenceIds);
            String transitionType = getTransitionString(transition);

            sendNotification(transitionType, ids);
        } else {
            Log.e(TAG, String.valueOf(LocationClient.getErrorCode(intent)));
        }
	}

    /**
     * Posts a notification in the notification bar when a transition is detected.
     * If the user clicks the notification, control goes to the main Activity.
     * @param transitionType The type of transition that occurred.
     *
     */
    private void sendNotification(String transitionType, String ids) {

        // Create an explicit content Intent that starts the main Activity
        Intent notificationIntent =
                new Intent(getApplicationContext(),MainActivity.class);

        // Construct a task stack
        TaskStackBuilder stackBuilder = TaskStackBuilder.create(this);

        // Adds the main Activity to the task stack as the parent
        stackBuilder.addParentStack(MainActivity.class);

        // Push the content Intent onto the stack
        stackBuilder.addNextIntent(notificationIntent);

        // Get a PendingIntent containing the entire back stack
        PendingIntent notificationPendingIntent =
                stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT);

        // Get a notification builder that's compatible with platform versions >= 4
        NotificationCompat.Builder builder = new NotificationCompat.Builder(this);

        // Set the notification contents
        builder.setSmallIcon(R.drawable.ic_launcher)
                .setContentTitle(
                        getString(R.string.geofence_transition_notification_title,
                                transitionType, ids))
                .setContentText(getString(R.string.geofence_transition_notification_text))
                .setContentIntent(notificationPendingIntent);

        // Get an instance of the Notification manager
        NotificationManager mNotificationManager =
                (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);

        // Issue the notification
        mNotificationManager.notify(0, builder.build());
    }

    private String getTransitionString(int transitionType) {
        switch (transitionType) {

            case Geofence.GEOFENCE_TRANSITION_ENTER:
                return getString(R.string.geofence_transition_entered);

            case Geofence.GEOFENCE_TRANSITION_EXIT:
                return getString(R.string.geofence_transition_exited);

            case Geofence.GEOFENCE_TRANSITION_DWELL:
                return getString(R.string.geofence_transition_dwell);

            default:
                return getString(R.string.geofence_transition_unknown);
        }
    }
}

We're using an IntentService because we want to still be able to handle transitions and dwelling Intents even when the application is closed. As long as the geofence has not expired, the LocationClient will continue to fire off Intents, which will be picked up and handled by the IntentService.onHandleIntent

On line 31 we're using the LocationClient to check to see if the Intent contains any errors and if it doesn't, we will proceed to handle the Intent. Once we know that there are no errors, we determine the transition type on line 32.

Line 36 retrieves the geofences that have been triggered. At this point, we have all the information we need to react accordingly. The rest of the function creates a notification reporting the type of transition and which geofence triggered it.