Android Geofences (Update)

By Paulus, 6 February, 2015

Introduction

A few months ago, I had written about how to setup and monitor geofences. Since then, Google has made changes to their location APIs by removing some of the classes such as the LocationClient class. This change was announced in 2013, but Google's documentation had made no mention of the changes that were to come. At the time of writing, documentation and examples on how to setup and monitor geofences using the new method are scarce.

Requirements

Make sure that you update your SDK packages so that you have Google Play services revision 22. If you are using eclipse and already have Google Play services as  project, you may need to clean and rebuild or reimport the code. If you need help setting up Google Play services, see this.  

Application

The AndroidManifest.xml file doesn't require any changes from my previous entry.

<?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" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />

    <!--
            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=".GeofenceIntentService" />

        <!-- 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="YOUR_GOOGLE_MAPS_API_KEY_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. The permission android.permission.WAKE_LOCK is used for notifications and is not required for geofencing.

We're specifying a service on line 38. 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>

    <string name="geofence_intent_service">Geofence Intent Service</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>

One of the reasons why Google changed the way geofences were handled in code was to make it easier for the developer. If you look at the old way, you will notice that there is a lot less code and logic that is required to make this work. Additionally, I moved the geofence logic into a different class called GeofenceStore. I did this to keep the main activity class as clean as possible.

package com.paulusworld.geofence;

import java.util.ArrayList;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GooglePlayServicesUtil;
import com.google.android.gms.location.Geofence;
import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.GoogleMap.OnCameraChangeListener;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.CameraPosition;
import com.google.android.gms.maps.model.CircleOptions;
import com.google.android.gms.maps.model.LatLng;

import android.graphics.Color;
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;

public class MainActivity extends FragmentActivity implements OnCameraChangeListener {

	/**
	 * Google Map object
	 */
	private GoogleMap mMap;

	/**
	 * Geofence Data
	 */

	/**
	 * Geofences Array
	 */
	ArrayList<Geofence> mGeofences;

	/**
	 * Geofence Coordinates
	 */
	ArrayList<LatLng> mGeofenceCoordinates;

	/**
	 * Geofence Radius'
	 */
	ArrayList<Integer> mGeofenceRadius;

	/**
	 * Geofence Store
	 */
	private GeofenceStore mGeofenceStore;


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

		// Initializing variables
		mGeofences = new ArrayList<Geofence>();
		mGeofenceCoordinates = new ArrayList<LatLng>();
		mGeofenceRadius = new ArrayList<Integer>();

		// Adding geofence coordinates to array.
		mGeofenceCoordinates.add(new LatLng(43.042861, -87.911559));
		mGeofenceCoordinates.add(new LatLng(43.042998, -87.909753));
		mGeofenceCoordinates.add(new LatLng(43.040732, -87.921364));
		mGeofenceCoordinates.add(new LatLng(43.039912, -87.897038));

		// Adding associated geofence radius' to array.
		mGeofenceRadius.add(100);
		mGeofenceRadius.add(50);
		mGeofenceRadius.add(160);
		mGeofenceRadius.add(160);

		// Bulding the geofences and adding them to the geofence array.

		// Performing Arts Center
		mGeofences.add(new Geofence.Builder()
				.setRequestId("Performing Arts Center")
				// The coordinates of the center of the geofence and the radius in meters.
				.setCircularRegion(mGeofenceCoordinates.get(0).latitude, mGeofenceCoordinates.get(0).longitude, mGeofenceRadius.get(0).intValue())
				.setExpirationDuration(Geofence.NEVER_EXPIRE)
				// Required when we use the transition type of GEOFENCE_TRANSITION_DWELL
				.setLoiteringDelay(30000)
				.setTransitionTypes(
						Geofence.GEOFENCE_TRANSITION_ENTER
							| Geofence.GEOFENCE_TRANSITION_DWELL
							| Geofence.GEOFENCE_TRANSITION_EXIT).build());

		// Starbucks
		mGeofences.add(new Geofence.Builder()
				.setRequestId("Starbucks")
				// The coordinates of the center of the geofence and the radius in meters.
				.setCircularRegion(mGeofenceCoordinates.get(1).latitude, mGeofenceCoordinates.get(1).longitude, mGeofenceRadius.get(1).intValue())
				.setExpirationDuration(Geofence.NEVER_EXPIRE)
				// Required when we use the transition type of GEOFENCE_TRANSITION_DWELL
				.setLoiteringDelay(30000)
				.setTransitionTypes(
						Geofence.GEOFENCE_TRANSITION_ENTER
							| Geofence.GEOFENCE_TRANSITION_DWELL
							| Geofence.GEOFENCE_TRANSITION_EXIT).build());

		// Milwaukee Public Museum
		mGeofences.add(new Geofence.Builder()
				.setRequestId("Milwaukee Public Museum")
				// The coordinates of the center of the geofence and the radius in meters.
				.setCircularRegion(mGeofenceCoordinates.get(2).latitude, mGeofenceCoordinates.get(2).longitude, mGeofenceRadius.get(2).intValue())
				.setExpirationDuration(Geofence.NEVER_EXPIRE)
				.setTransitionTypes(
						Geofence.GEOFENCE_TRANSITION_ENTER
							| Geofence.GEOFENCE_TRANSITION_EXIT).build());

		// Milwaukee Art Museum
		mGeofences.add(new Geofence.Builder()
				.setRequestId("Milwaukee Art Museum")
				// The coordinates of the center of the geofence and the radius in meters.
				.setCircularRegion(mGeofenceCoordinates.get(3).latitude, mGeofenceCoordinates.get(3).longitude, mGeofenceRadius.get(3).intValue())
				.setExpirationDuration(Geofence.NEVER_EXPIRE)
				.setTransitionTypes(
						Geofence.GEOFENCE_TRANSITION_ENTER
							| Geofence.GEOFENCE_TRANSITION_EXIT).build());

		// Add the geofences to the GeofenceStore object.
		mGeofenceStore = new GeofenceStore(this, mGeofences);

	}

	@Override
	protected void onStart() {
		super.onStart();
	}

	@Override
	protected void onStop() {
		mGeofenceStore.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(
				43.039634, -87.908395), 14));

		// Hide labels.
		mMap.setMapType(GoogleMap.MAP_TYPE_HYBRID);
		mMap.setIndoorEnabled(false);
		mMap.setMyLocationEnabled(true);

		mMap.setOnCameraChangeListener(this);

	}

	@Override
	public void onCameraChange(CameraPosition position) {
		// Makes sure the visuals remain when zoom changes.
		for(int i = 0; i < mGeofenceCoordinates.size(); i++) {
			mMap.addCircle(new CircleOptions().center(mGeofenceCoordinates.get(i))
					.radius(mGeofenceRadius.get(i).intValue())
					.fillColor(0x40ff0000)
					.strokeColor(Color.TRANSPARENT).strokeWidth(2));
		}
	}
}

 

package com.paulusworld.geofence;

import java.util.ArrayList;

import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.location.Location;
import android.os.Bundle;
import android.util.Log;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.GoogleApiClient.ConnectionCallbacks;
import com.google.android.gms.common.api.GoogleApiClient.OnConnectionFailedListener;
import com.google.android.gms.common.api.PendingResult;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.common.api.Status;
import com.google.android.gms.location.Geofence;
import com.google.android.gms.location.GeofencingRequest;
import com.google.android.gms.location.LocationListener;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationServices;

public class GeofenceStore implements ConnectionCallbacks,
		OnConnectionFailedListener, ResultCallback<Status>, LocationListener {

	private final String TAG = this.getClass().getSimpleName();

	/**
	 * Context
	 */
	private Context mContext;

	/**
	 * Google API client object.
	 */
	private GoogleApiClient mGoogleApiClient;

	/**
	 * Geofencing PendingIntent
	 */
	private PendingIntent mPendingIntent;

	/**
	 * List of geofences to monitor.
	 */
	private ArrayList<Geofence> mGeofences;

	/**
	 * Geofence request.
	 */
	private GeofencingRequest mGeofencingRequest;

	/**
	 * Location Request object.
	 */
	private LocationRequest mLocationRequest;

	/**
	 * Constructs a new GeofenceStore.
	 *
	 * @param context The context to use.
	 * @param geofences List of geofences to monitor.
	 */
	public GeofenceStore(Context context, ArrayList<Geofence> geofences) {
		mContext = context;
		mGeofences = new ArrayList<Geofence>(geofences);
		mPendingIntent = null;

		// Build a new GoogleApiClient, specify that we want to use LocationServices
		// by adding the API to the client, specify the connection callbacks are in
		// this class as well as the OnConnectionFailed method.
		mGoogleApiClient = new GoogleApiClient.Builder(context)
				.addApi(LocationServices.API).addConnectionCallbacks(this)
				.addOnConnectionFailedListener(this).build();

		// This is purely optional and has nothing to do with geofencing.
		// I added this as a way of debugging.
		// Define the LocationRequest.
		mLocationRequest = new LocationRequest();
		// We want a location update every 10 seconds.
		mLocationRequest.setInterval(10000);
		// We want the location to be as accurate as possible.
		mLocationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

		mGoogleApiClient.connect();
	}

	@Override
	public void onResult(Status result) {
		if (result.isSuccess()) {
			Log.v(TAG, "Success!");
		} else if (result.hasResolution()) {
			// TODO Handle resolution
		} else if (result.isCanceled()) {
			Log.v(TAG, "Canceled");
		} else if (result.isInterrupted()) {
			Log.v(TAG, "Interrupted");
		} else {

		}

	}

	@Override
	public void onConnectionFailed(ConnectionResult connectionResult) {
		Log.v(TAG, "Connection failed.");
	}

	@Override
	public void onConnected(Bundle connectionHint) {
		// We're connected, now we need to create a GeofencingRequest with
		// the geofences we have stored.
		mGeofencingRequest = new GeofencingRequest.Builder().addGeofences(
				mGeofences).build();

		mPendingIntent = createRequestPendingIntent();

		// This is for debugging only and does not affect
		// geofencing.
		LocationServices.FusedLocationApi.requestLocationUpdates(
				mGoogleApiClient, mLocationRequest, this);

		// Submitting the request to monitor geofences.
		PendingResult<Status> pendingResult = LocationServices.GeofencingApi
				.addGeofences(mGoogleApiClient, mGeofencingRequest,
						mPendingIntent);

		// Set the result callbacks listener to this class.
		pendingResult.setResultCallback(this);
	}

	@Override
	public void onConnectionSuspended(int cause) {
		Log.v(TAG, "Connection suspended.");
	}

	/**
	 * This creates a PendingIntent that is to be fired when geofence transitions
	 * take place. In this instance, we are using an IntentService to handle the
	 * transitions.
	 *
	 * @return A PendingIntent that will handle geofence transitions.
	 */
	private PendingIntent createRequestPendingIntent() {
		if (mPendingIntent == null) {
			Log.v(TAG, "Creating PendingIntent");
			Intent intent = new Intent(mContext, GeofenceIntentService.class);
			mPendingIntent = PendingIntent.getService(mContext, 0, intent,
					PendingIntent.FLAG_UPDATE_CURRENT);
		}

		return mPendingIntent;
	}

	@Override
	public void onLocationChanged(Location location) {

		Log.v(TAG, "Location Information\n"
				+ "==========\n"
				+ "Provider:\t" + location.getProvider() + "\n"
				+ "Lat & Long:\t" + location.getLatitude() + ", "
				+ location.getLongitude() + "\n"
				+ "Altitude:\t" + location.getAltitude() + "\n"
				+ "Bearing:\t" + location.getBearing() + "\n"
				+ "Speed:\t\t" + location.getSpeed() + "\n"
				+ "Accuracy:\t" + location.getAccuracy() + "\n");
	}
}

The GeofenceIntentService is a basic class that handles Intent broadcasts from the LocationServices API. The sendNotification and getTriggeringGeofences are just additional logic code that is executed when a Geofence transition is broadcast.

package com.paulusworld.geofence;

import java.util.List;

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

import android.app.IntentService;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.os.PowerManager;
import android.support.v4.app.NotificationCompat;
import android.text.TextUtils;
import android.util.Log;

public class GeofenceIntentService extends IntentService {

	private final String TAG = this.getClass().getCanonicalName();

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

	public void onCreate() {
		super.onCreate();
		Log.v(TAG, "onCreate");
	}

	public void onDestroy() {
		super.onDestroy();
		Log.v(TAG, "onDestroy");
	}

	@Override
	protected void onHandleIntent(Intent intent) {
		GeofencingEvent geofencingEvent = GeofencingEvent.fromIntent(intent);
		Log.v(TAG, "onHandleIntent");
		if(!geofencingEvent.hasError()) {
			int transition = geofencingEvent.getGeofenceTransition();
			String notificationTitle;

			switch(transition) {
			case Geofence.GEOFENCE_TRANSITION_ENTER:
				notificationTitle = "Geofence Entered";
				Log.v(TAG, "Geofence Entered");
				break;
			case Geofence.GEOFENCE_TRANSITION_DWELL:
				notificationTitle = "Geofence Dwell";
				Log.v(TAG, "Dwelling in Geofence");
				break;
			case Geofence.GEOFENCE_TRANSITION_EXIT:
				notificationTitle = "Geofence Exit";
				Log.v(TAG, "Geofence Exited");
				break;
			default:
				notificationTitle = "Geofence Unknown";
			}

			sendNotification(this, getTriggeringGeofences(intent), notificationTitle);
		}
	}

	private void sendNotification(Context context, String notificationText,
			String notificationTitle) {

		PowerManager pm = (PowerManager) context
				.getSystemService(Context.POWER_SERVICE);
		PowerManager.WakeLock wakeLock = pm.newWakeLock(
				PowerManager.PARTIAL_WAKE_LOCK, "");
		wakeLock.acquire();

		NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(
				context).setSmallIcon(R.drawable.ic_launcher)
				.setContentTitle(notificationTitle)
				.setContentText(notificationText)
				.setDefaults(Notification.DEFAULT_ALL).setAutoCancel(false);

		NotificationManager notificationManager = (NotificationManager) context
				.getSystemService(Context.NOTIFICATION_SERVICE);
		notificationManager.notify(0, notificationBuilder.build());

		wakeLock.release();
	}

	private String getTriggeringGeofences(Intent intent) {
		GeofencingEvent geofenceEvent = GeofencingEvent.fromIntent(intent);
		List<Geofence> geofences = geofenceEvent
				.getTriggeringGeofences();

		String[] geofenceIds = new String[geofences.size()];

		for (int i = 0; i < geofences.size(); i++) {
			geofenceIds[i] = geofences.get(i).getRequestId();
		}

		return TextUtils.join(", ", geofenceIds);
	}
}

In order to get Geofence information from the intent received in the IntentService, we create a GeofencingEvent by calling GeofencingEvent.fromIntent(Intent intent) on line 39. On line 41, we make sure there are no errors before getting the transition on line 42 by calling the getGeofenceTransition function. From that point, the rest of the function is self explanatory and the rest of the class is beyond the scope of Geofencing.

There is one important thing to remember when working with geofences, transition broadcasts won't be fired if the user is not completely in the geofence. Accuracy is also taken into account when determining if a user has entered, exitied, or is dwelling. For example, if a geofence has a diameter of 100 meters and your accuracy is 150 meteres even if you are shown as being smack in the center of the geofence, no transitions will be fired because Google isn't sure if you are in the geofence or not.