Tuesday, December 15, 2009

Gracefully supporting multiple Android platform versions in a single release

The Android platform has been aggressively updating since version 1.0 and now we're starting to a see a much more interesting mix of device types, manufacturers, and even platform versions out in the wild. Unfortunately sometimes this can be frustrating for developers wanting to look forward to support new features and conveniences, but to still support devices that are on longer update cycles (like with the G1).

The pattern shown here will deal with multiple platform versions although can easily be applied in other situations. First of all, let's start with a preface about minSdkVersion, targetSdkVersion, and the Eclipse target platform. The *SdkVersion attributes are defined in the manifest <uses-sdk> tag and define the minimum platform version your app can be installed onto (and tested on!), and the highest version that you tested to and were aware of during development. It is important that you test your application on all versions between and including min and target. The Eclipse target platform is the specific version that Eclipse will be compiling against, this is what permits us to compile code that actually does link specifically against the newer platform features. This is usually set the same as your targetSdkVersion.

Now let's consider a practical example of a music player application which needs to implement a service in the foreground state during playback. Prior to API level 5, this was done with the Service.setForeground call, but level 5 and beyond deprecated this method due to widespread abuse. Instead, a new method was introduced (Service.startForeground) which can be used to achieve this affect as well as setting an ongoing notification in the status bar. In many ways this is handy as the notification and foreground state were naturally already tied together, now there's an API combining them. But problems start when you try to test new code using this method on platform versions below 2.0 (API level 5). Specifically, Dalvik will throw a VerifyError when attempting to initialize the class containing the call to startForeground for the first time, even if the call is in a conditional statement. This method does not exist on pre-2.0 devices, and so cannot be included in your code in this way.

A naive approach would be to simply use reflection to test for and execute startForeground, but thankfully Java offers a much more elegant design pattern for just this sort of thing. The basic idea is to create an abstract API that the rest of your application can access which hides the specific implementation of what's being performed, and does so in such a way that prevents the VM from initializing an unsupported class on an older platform. So you might try defining something like this:


public abstract class PlayerNotification {
public static PlayerNotification getInstance() {
if (Integer.parseInt(Build.VERSION.SDK) <= 4)
return PreEclair.Holder.sInstance;
else
return EclairAndBeyond.Holder.sInstance;
}

public abstract void showNotification(Service context, int id, Notification notification);
public abstract void hideNotification(Service context, int id);

private static class PreEclair extends PlayerNotification {
...
}

private static class EclairAndBeyond extends PlayerNotification {
...
}
}


Now your service could be modified to make use of this new abstract API as such:


public MyService extends Service {
private static final int NOTIF_PLAYING = 1;
private final PlayerNotification mNotification =
PlayerNotification.getInstance();

...

public void setForegroundAndShowNotification(Notification n) {
mNotification.showNotification(this, NOTIF_PLAYING, n);
}

public void stopForegroundAndHideNotification() {
mNotification.hideNotification(this, NOTIF_PLAYING);
}
}


Great, this sounds very simple and easy to follow. Let's return to the full implementation of PlayerNotification:


private static class PreEclair extends PlayerNotification {
private static class Holder {
private static final PreEclair sInstance = new PreEclair();
}
private NotificationManager getNotificationManager(Context context) {
return (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
}
public void showNotification(Service context, int id, Notification n) {
context.setForeground(true);
getNotificationManager(context).notify(id, n);
}
public void hideNotification(Service context, int id) {
context.setForeground(false);
getNotificationManager(context).cancel(id);
}
}

private static class EclairAndBeyond extends PlayerNotification {
private static class Holder {
private static final EclairAndBeyond sInstance = new EclairAndBeyond();
}
public void showNotification(Service context, int id, Notification n) {
context.startForeground(id, n);
}
public void hideNotification(Service context, int id) {
context.stopForeground(id);
}
}


And that's it as far as code goes! Assuming that you have already updated your AndroidManifest.xml to include the appropriate <uses-sdk> attributes, you're ready to start testing. Use the Android SDK tools to create AVDs for each of the major platform releases from your minimum supported version to your current target and deploy your app on each to make sure you have not made any mistakes.

For further reading about how Java guarantees this approach, read about the initialization on demand holder idiom. This is what allows us to prevent the wrong implementing class from initializing in the VM (and thus causing verification errors).

You can find two working examples of this pattern in my Five app: one using reflection and one matching the explained example