Monday, January 10, 2011

How To: Use the Gyroscope API and Remain Android 1.1 Compatible

Making your app backwards compatible is the process of ensuring it degrades gracefully. 

Every new Android SDK release introduces a variety of new APIs

It takes time for each new OS version to percolate through the ecosystem, so while new functionality is cool, but it introduces a dilemma - use the new hotness or support the majority of users?

The answer of course is to do both! Make use of useful new APIs where they're available, and fall back to an earlier alternative (or disable functionality) when necessary.

Let's look at a practical example
My Nexus S has a gyroscope sensor that I can use to stabilize my artificial horizon / compass app (New Horizons). I'll save most of the implementation details for a separate post, and focus instead on how I can adjust my code to go from supporting only Android 2.3, to supporting everything from Android 1.0 up.
The platform version distribution shows 99.9% of devices are now running at least Android 1.5, and more than 75% are running 2.1+, so in this case achieving 1.0 compatibility is a contrivance that lets me demonstrate a number of useful techniques within a single example.
I'll start by reacting to orientation changes using the gyroscope in 2.3 and then remove hardware and API features until we end up compatible with a factory-fresh HTC G1.

Using the gyroscope sensor 

You'll need code that looks a little like this:

private void hookupSensorListener() {
  SensorManager sm = (SensorManager)getSystemService(Context.SENSOR_SERVICE);
  sm.registerListener(sel,
    sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE),
    SensorManager.SENSOR_DELAY_UI);
}

private final SensorEventListener sel = new SensorEventListener() {
  public void onSensorChanged(SensorEvent event) {
    if (event.sensor.getType() == Sensor.TYPE_GYROSCOPE) {
      updateOrientation(event.values[0], event.values[1], event.values[2]);
    }
  }
  
  public void onAccuracyChanged(Sensor sensor, int accuracy) {}
};

private void updateOrientation(float heading, float pitch, float roll) {
  // Update the UI
}


Within the updateOrientation method you react to each orientation change (for a gyro that's going to mean integrating the changes in angular velocity) and eventually extract values you'll use to update the UI.

Case 1: What if there is no gyroscope?

Can we implement the same functionality with different hardware? In this case yes, we can use the accelerometers to determine our orientation.

Start by creating a listener interface that can listen for orientation changes:

public interface OrientationChangeListener {
  public void onUpdate(float heading, float pitch, float roll);
  public void onAccuraryChanged(int accuracy);
}


Then define another interface that can be implemented to handle orientation updates from different sensor hardware:

public interface IOrientationSensorListener {
  public void setOrientationChangeListener(OrientationChangeListener l);
  public void registerListener(SensorManager sensorManager);
  public void unregisterListener(SensorManager sensorManager);
}


Create two implementations of the IOrientationSensorListener class, one that uses the gyro and the other that uses accelerometers:
  • GyroOrientationSensorListener
  • AccOrientationSensorListener
The orientation updates are encapsulated in their hardware-specific implementations, so we need to implement an OrientationChangeListener that will receive their orientation updates and update the UI accordingly:

final OrientationChangeListener ocl = new OrientationChangeListener() {
  public void onUpdate(float heading, float pitch, float roll,) {
    updateOrientation(heading, pitch, roll);
  }

  public void onAccuraryChanged(int accuracy) {
    updateAccuracy(accuracy);
  }
});


Now we determine if the gyro is available:

PackageManager paM = getPackageManager();
boolean gyroExists = paM.hasSystemFeature(PackageManager.FEATURE_SENSOR_GYROSCOPE);


And instantiate the appropriate IOrientationSensorListener implementation:

IOrientationSensorListener mySensorListener;
if (gyroExists)
  mySensorListener = new GyroOrientationSensorListener();
else
  mySensorListener = new AccOrientationSensorListener();


Before hooking up the orientation change listener:

mySensorListener.setOrientationChangeListener(ocl);
For cases where there is no viable alternative hardware, you can either disable that functionality completely, or specify the required hardware as a mandatory feature:

<uses-feature android:name="android.hardware.sensor.gyroscope"/>

Note that this will prevent your app from being visible on the Market for devices without the required hardware.

Case 2: What if the APIs you're using are missing?

While your Android 2.3 app will install and run on any version of the Android OS, it will crash the moment it tries to use a class or method that doesn't exist on that device. To figure out what needs to be fixed, you can modify your project's build target to the lowest OS version you intend to support and see what won't compile.

Looking at my code above, I can see the following constants, methods, and classes were introduced since Android 1.0:
  • API Level 9: PackageManager.FEATURE_SENSOR_GYROSCOPE
  • API Level 6: PackageManager.hasSystemFeature()
  • API Level 3: Sensor, SensorEvent, and SensorEventListener
Dealing with new constants

New constants will work without any changes. The compiler will convert them into the value they represent and the methods into which you pass them should be able to handle with unrecognized values cleanly.

In this instance I pass a string constant into the hasSystemFeature method that assumes any feature it doesn't recognize isn't available and returns false.

We're now compatible with API Level 6 (Android 2.0.1)!

Dealing with missing methods

Missing methods are a little more complicated. In this case the PackageManager class has been extended to include the hasSystemFeature method. If you call this method on a device running Android 1.6 (or below) it will throw an exception and crash.

To avoid this you can:
  • Use reflection at runtime to determine if the method exists.
  • Replace the method with an alternative that works on previous OS versions.
Reflection is expensive, so first we'll check if there's a cheaper alternative that doesn't add significant complexity or cost.

In this case I can use SensorManager.getDefaultSensor to check if there is a default gyroscope sensor available.

boolean gyroExists = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null;

We're now compatible with API Level 3 (Android 1.5), and given the distribution of Android OS versions in the wild, for most cases we'd be done.

We still haven't looked at dealing with missing classes though, so for the sake of completeness let's go one step further and ensure support for Android 1.0.

Dealing with missing classes

The Sensor, SensorEvent, and SensorEventListener classes were all added in Android 1.5 (along with a number of useful SensorManager methods including getDefaultSensor used above).

Let's start by using reflection to see if the new Sensor classes and methods are available. This can be expensive so it's important to perform the check only once per application launch. We'll also assume that the existence of one 1.5+ class implies the existence of the rest.

private static boolean sensorListenerExists = false;
 
private static void checkSensorEventListenerExists() throws NoClassDefFoundError {
  sensorEventListenerExists = SensorEventListener.class != null;
}
 
static {
  try {
    checkSensorEventListenerExists();
  } catch (NoClassDefFoundError e) {
    sensorEventListenerExists = false;
  }
}


To avoid an exception on calling getDefaultSensor we need to move this call into a new class that's only used if the new method is available. Let's expose the functionality using a static method to avoid the need to create any new objects:

public class SensorEventHasGyro {
  public static boolean gyroExists(SensorManager sensorManager) {
    return sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null;
  }
}


Gyroscopes weren't supported prior to Android 1.5, so we can check for a gyroscope sensor using the following code:

boolean gyroExists = sensorListenerExists && 
                     SensorEventHasGyro.gyroExists(sensorManager);

Finally, we create a new implementation of the OrientationSensorListener that uses the old technique for calculating orientation from accelerometers (which is so deprecated it's not worth repeating here), then modify the code used to select and instantiate the correct implementation.

if (gyroExists)
  mySensorListener = new GyroOrientationSensorListener();
else if (sensorListenerExists)
  mySensorListener = new AccOrientationSensorListener();
else 
  mySensorListener = new AccOldOrientationSensorListener();

Summary

While providing support for Android 1.0 as done in this example is a little contrived, the same techniques will work in cases like the Download Manager or SIP stack where the majority of users don't have access to the newer versions of the OS.

Why wait until everyone is using Android 2.3 to make use of the gyroscope or download manager? Using these techniques you can build it in right now, and simply provide an alternative implementation for users who aren't fortunate enough to have the latest hardware / software.

5 comments:

  1. Anonymous6:30 p.m. GMT

    Thanks a lot for the article

    ReplyDelete
  2. Thank you for your great post! I already used this functionality in an app to be backwards-compatible down to Android 1.6 while still using the new StrictMode class of Gingerbread.
    That is when I thought development for Android developers could be improved by showing warnings in Eclipse when developers are using high-level API while being compatible with older Android versions:
    http://code.google.com/p/android/issues/detail?id=13905

    What do you think about this feature request? Of course it will not eliminate testing on older versions in the emulator, but it could help to know where the pitfalls are...

    ReplyDelete
  3. Too funny that I just blogged about the same technique for using StrictMode a couple of days ago. http://goo.gl/JJScS

    ReplyDelete
  4. Thanks for posting this useful article. Can you also share how you exactly update the orientation? How about its accuracy, stability and response speed when compared to using accelerometer and compass together without gyroscope? Thanks.

    ReplyDelete
  5. I recently saw a post from Dianne Hackborn that said sometimes classes make it into a release that aren't ready to be used. Which implies that Reflection could find a class that won't work properly. That tells me I should always use the android.os.Build.VERSION.SDK_INT field (and android.os.Build.VERSION.SDK field if I want to support below 1.6) to figure out which version I'm on, and decide based on that if a class or method or field is available. Or maybe even a combination to make absolutely sure that the device manufacturer has not messed with the Android SDK. Do you agree?

    ReplyDelete