Jul 16, 2012

Connectivity and Battery Life in Android. Making them play nicely.

Last week I announced Stocktile, a stock market application to make my life easier and enjoy checking on my tickers even more. At the end of that post, I wrote the biggest lesson I learnt while I was developing the app:
Main challenge: The web is always online. Mobile applications are not. It's an entire layer of complexity added to our applications.
Indeed. Not having a reliable and fast Internet connection all the time is something traditional developers are not used to. Mobile imposes this restriction, and we have to deal with it if we want to make our applications solid options for consumers.

But it's not just about connectivity

There's another challenge though that we don't need to worry about outside the mobile ecosystem: battery life. In such small devices, there's a very small battery and dozen of applications competing to use a pice of it. Unfortunately, technology is still not where we can stop worrying about how much power our application sucks, so we need to use it carefully or face the anger of our users.

So connectivity and battery life are the main two players. They need to play seamlessly and safe. Unfortunately is difficult to get it right, so a lot of developers don't pay attention to these details. That's mainly why tons of Android apps are battery and data-plans killers. They just don't care.

On the Android developers site there are two awesome articles explaining the ins and outs of developing and application that properly takes care of connectivity and battery life. I'm not going to reproduce here everything said, but I strongly recommend any developer to read those training sessions. I want to focus instead in the code that powers Stocktile, and how I combined different techniques to make the application a good citizen in Android-land.

Setting our rules

First of all, take into account that every application is different and requires a different level of thinking. What works for me, might not work for a news application or any other category. However, the approach is very similar, so I hope to help more than one out there.

Stocktile downloads the market information for every stock ticker added to your dashboard. You can have 3 tickers, or hundreds of them. Not matter how many, you surely want them to display the latest market information available all the time. That's the point, and that was my goal. However, because I'm feeding my app using the Yahoo! Finance API, there's going to be a minimum delay of 15 minutes between market updates. That's the first constraint I have to play with: no updates will be requested if the latest one was less than 15 minutes ago.

But what happens if the user is not using the application? Do we want to keep updating it? Remember that every update will impact the user's data-plan and the battery life of the device, so the answer is no. If the users don't care about our app, why bothering them with updates they are not going to see? So that's the second rule: the application will be updated only if users are actively using it.

However, downloading information from the network takes time, and we don't want our users waiting from fresh data to come when they open our app. So we should rethink our updates policies: what about updating automatically even if the user is not actively using the app but only when the device is plugged to a power source and connected to a WiFi network? That sounds better!

There are two more use cases that I had to take care of: when the user adds a new ticker to the dashboard, we want to update it immediately. And, if during an update an error occurs, we need to re-schedule the next update as soon as possible. However, it might be the case that something is wrong with the server so a continuous update will further damage the battery life of the device. To solve this, we need to employ a back-off pattern as explained on the Android training classes.

So let's put everything together:
  1. The minimun time between updates will be 15 minutes.
  2. If the user is not using the application, new information is going to be downloaded only when the device is plugged to a power source and running on a WiFi network.
  3. If the user adds a new ticker, an update will be performed immediately only for that ticker.
  4. If an error occurs while updating, a new back-off update will be scheduled as soon as possible.

The BatteryBroadcastReceiver

Let's take a look to all the code pieces that make up these guidelines. Let's start with our second rule above. For that one, we need to create two different BroadcastReceivers, one for monitoring the battery and the other to monitoring connectivity changes. Look at the following lines in our manifest file:
<receiver
    android:name=".BatteryBroadcastReceiver"
    android:exported="false" >
    <intent-filter>
        <action android:name="android.intent.action.ACTION_POWER_CONNECTED" />
        <action android:name="android.intent.action.ACTION_POWER_DISCONNECTED" />
    </intent-filter>
</receiver>
Then our Java class:
public class BatteryBroadcastReceiver extends BroadcastReceiver {

    private final static String LOG_TAG = BatteryBroadcastReceiver.class.getName();

    @Override
    public void onReceive(Context context, Intent intent) {
        if (isTheBatteryPluggedIn(context)) {
            if (areWeUsingWiFi(context)) {
                Log.d(LOG_TAG, "On WiFi and charging, let's update");

                Intent intent = new Intent(context, MarketCollectionReceiver.class);
                intent.setAction(Constants.SCHEDULE_BACKGROUND);
                PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 
                    PendingIntent.FLAG_UPDATE_CURRENT);

                ((AlarmManager) context.getSystemService(Context.ALARM_SERVICE))
                    .setInexactRepeating(AlarmManager.RTC_WAKEUP,
                        System.currentTimeMillis(),
                        AlarmManager.INTERVAL_HALF_HOUR,
                        pendingIntent);
            }
   
            ComponentName componentName = new ComponentName(
                context,
                ConnectivityBroadcastReceiver.class);          
            context.getPackageManager()
                .setComponentEnabledSetting(componentName,
                 PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
                 PackageManager.DONT_KILL_APP);
        }
        else {
            Log.d(LOG_TAG, "Not charging anymore. Hold off in any new updates");
                
            Intent intent = new Intent(context, MarketCollectionReceiver.class);
            intent.setAction(Constants.SCHEDULE_BACKGROUND);
            PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 
                PendingIntent.FLAG_UPDATE_CURRENT);

            ((AlarmManager) context.getSystemService(Context.ALARM_SERVICE))
                .cancel(pendingIntent);
   
            SharedPreferences pref = context.getSharedPreferences(
                Constants.PREFERENCES, Context.MODE_PRIVATE);

            boolean areWeWaitingForConnectivity = 
                pref.getBoolean(Constants.PREFERENCE_STATUS_WAITING_FOR_CONNECTIVITY, false);
   
            if (!areWeWaitingForConnectivity) {
                ComponentName componentName = new ComponentName(context, 
                    ConnectivityBroadcastReceiver.class);
                context.getPackageManager().setComponentEnabledSetting(
                   componentName, 
                   PackageManager.COMPONENT_ENABLED_STATE_DISABLED, 
                   PackageManager.DONT_KILL_APP);
            }
        }
    }
}
Looks more complicated than what really is. Let's go step by step. The very first line asks whether the battery is plugged in or not. Here is that code:
public boolean isTheBatteryPluggedIn(Context context) {
    Intent batteryIntent = context.getApplicationContext()
        .registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
 
    int plugged = batteryIntent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
    return plugged == BatteryManager.BATTERY_PLUGGED_AC 
        || plugged == BatteryManager.BATTERY_PLUGGED_USB;
}
Then we ask whether we are using WiFi:
public static boolean areWeUsingWiFi(Context context) {
    ConnectivityManager connectivityManager = 
        (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
  
    NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();

    boolean isConnected = networkInfo != null ? networkInfo.isConnected() : false;
    boolean isWiFi = isConnected 
        ? networkInfo.getType() == ConnectivityManager.TYPE_WIFI : false;

    return isWiFi;
}
In case both conditions are true updates are safe, so we can schedule a regular update using the AlarmManager service. Note how these updates will be performed every 30 minutes since the user is not necessarily using the application.

After the update is scheduled, another important thing is done in the code: a BroadcastReceiver for connectivity updates is enabled. Why? Because we need to be notified as soon as the user is not longer connected to the WiFi network. Since connectivity updates are very frequent, we don't want to keep the BroadcastReceiver enabled all the time, so we enable and disable it as needed.

Now, what happens if the battery is not plugged in? Well, we need to hold off in any new updates, so we go ahead and cancel any pending schedule. Also, if we are not actively waiting for the device to go back online, we can also disable the connectivity BroadcastReceiver until we get back plugged in to the wall. Pay special attention to the areWeWaitingForConnectivity flag. This is going to be true in case an error occurred while updating and we are waiting for the connectivity to come back to re-run the update.

The ConnectivityBroadcastReceiver

First, we need to declare our receiver in the manifest file. Note how we want it to be disabled by default. Our code will take care of enabling it only when necessary:
 <receiver
    android:name=".receivers.ConnectivityBroadcastReceiver"
    android:enabled="false"
    android:exported="false" >
    <intent-filter>
        <action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
        </intent-filter>
</receiver>
Then the Java class:
public class ConnectivityBroadcastReceiver extends BroadcastReceiver {
    private final static String LOG_TAG = ConnectivityBroadcastReceiver.class.getName();

    @Override
    public void onReceive(Context context, Intent intent) {
        if (areWeOnline(context)) {
            SharedPreferences pref = context.getSharedPreferences(Constants.PREFERENCES, 
                Context.MODE_PRIVATE);
            boolean wereWeWaitingForConnectivity = sharedPreferences.getBoolean(
                Constants.PREFERENCE_STATUS_WAITING_FOR_CONNECTIVITY, false);

            if (wereWeWaitingForConnectivity) {
                Log.d(LOG_TAG, "Connectivity was just re-established, let's update.");
            
                DataProvider.startStockQuoteCollectorService(context, null);

                ComponentName componentName = new ComponentName(
                    context, ConnectivityBroadcastReceiver.class);
                context.getPackageManager().setComponentEnabledSetting(
                   componentName, 
                   PackageManager.COMPONENT_ENABLED_STATE_DISABLED, 
                   PackageManager.DONT_KILL_APP);
            }       
            else {
                if (areWeUsingWiFi(context)) {
                    if (isTheBatteryPluggedIn(context)) {
                        Log.d(LOG_TAG, "We are on WiFi and charging, so let's update");

                        Intent intent = new Intent(context, MarketCollectionReceiver.class);
                        intent.setAction(Constants.SCHEDULE_BACKGROUND);
                        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, 
                            intent, 
                            PendingIntent.FLAG_UPDATE_CURRENT);

                        ((AlarmManager) context.getSystemService(Context.ALARM_SERVICE))
                            .setInexactRepeating(AlarmManager.RTC_WAKEUP,
                                System.currentTimeMillis(),
                                AlarmManager.INTERVAL_HALF_HOUR,
                                pendingIntent);
                   }
               }
               else {
                   Log.d(LOG_TAG, "We aren't using WiFi, so don't update");

                   Intent intent = new Intent(context, MarketCollectionReceiver.class);
                   intent.setAction(Constants.SCHEDULE_BACKGROUND);
                   PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, 
                       intent, 
                       PendingIntent.FLAG_UPDATE_CURRENT);

                   ((AlarmManager) context.getSystemService(Context.ALARM_SERVICE))
                       .cancel(pendingIntent);
               }
           }
       }
       else {
           Log.d(LOG_TAG, "We aren't online so don't update.");

           Intent intent = new Intent(context, MarketCollectionReceiver.class);
           intent.setAction(Constants.SCHEDULE_BACKGROUND);
           PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 
               PendingIntent.FLAG_UPDATE_CURRENT);

           ((AlarmManager) context.getSystemService(Context.ALARM_SERVICE))
               .cancel(pendingIntent);
        }
    }
}
Very similar to the BatteryBroadcastReceiver, but in this case we start asking if we are online (not only on a WiFi, but with any Internet access):
public boolean areWeOnline(Context context) {
    ConnectivityManager connectivityManager = (ConnectivityManager)     
        context.getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();

    return networkInfo != null ? networkInfo.isConnected() : false;
}
If we are, and we were waiting for the connectivity to come back (an error occurred while updating), then we run an update immediately and disable the BroadcastReceiver. If we don't need an immediate update, then we ask whether the battery is plugged in, and we are on a WiFi connection. From there, everything is pretty much the same to the BatteryBroadcastReceiver.

Receiving alarms and firing updates

These two BroadcastReceivers work pretty well in conjunction to schedule our background updates, but there's another piece they need to fire up the updates: another BroadcastReceiver that's going to be kicked off by the scheduled alarms. Round of applauses for our MarketCollectionReceiver:
public class MarketCollectionReceiver extends BroadcastReceiver {
    private final static String LOG_TAG = MarketCollectionReceiver.class.getName();

    @Override
    public void onReceive(Context context, Intent intent) {
        if (intent.getAction().equals(Constants.SCHEDULE_RETRY)) {
            SharedPreferences pref = context.getSharedPreferences(
                Constants.PREFERENCES, Context.MODE_PRIVATE);

            boolean retrying = pref.getBoolean(
                Constants.PREFERENCE_COLLECTOR_RETRYING, false);

            if (retrying) {
                Log.d(LOG_TAG, "Retrying update...");
                DataProvider.startStockQuoteCollectorService(context, null);
            }
            else {
                Log.d(LOG_TAG, "Update was already successfully performed");
            }
        }
        else if (intent.getAction().equals(Constants.SCHEDULE_BACKGROUND)) {
            if (isTheBatteryPluggedIn(context) && areWeUsingWiFi(context)) {
                Log.d(LOG_TAG, "Performing background update...");
                DataProvider.startStockQuoteCollectorService(context, null);
            }
            else {
                Log.d(LOG_TAG, "We are not longer able to perform background updates");
                Intent intent = new Intent(context, MarketCollectionReceiver.class);
                intent.setAction(Constants.SCHEDULE_BACKGROUND);
                PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, 
                    intent, 
                    PendingIntent.FLAG_UPDATE_CURRENT);

                ((AlarmManager) context.getSystemService(Context.ALARM_SERVICE))
                    .cancel(pendingIntent);
            }
        }
        else if (intent.getAction().equals(Constants.SCHEDULE_AUTOMATIC)) {
            Log.d(LOG_TAG, "Performing automatic update");
            DataProvider.startStockQuoteCollectorService(context, null);
        }
    }
}
If you take a closer look to the code above, you'll notice that this BroadcastReceiver is using the action of the passed Intent to determine what kind of update was scheduled. In our Battery and Connectivity broadcast receivers we only use "BACKGROUND" updates, but the application uses three different types: BACKGROUND, RETRY, and AUTOMATIC. When the device is plugged in, and is on WiFi, we fire a BACKGROUND update. If an error occurs while updating, we fire a RETRY update. If the user is actively using the application or a new ticker is added, we fire an AUTOMATIC update. The rest of the code, should be self-explanatory.

Now, what about our back-off updates?

That happens when the update occurs. I'm not going to post the entire Service that takes care of the updates, but just the relevant sections:
...
int retries = sharedPreferences.getInt(Constants.PREFERENCE_COLLECTOR_RETRIES, 0);
...                 

if (retrying || wereWeWaitingForConnectivity || areWeUpdatingOnlyOneTicker 
    || (!areWeUpdatingOnlyOneTicker 
        && currentTime - lastUpdate > Constants.COLLECTOR_MIN_REFRESH_INTERVAL)) {
    try {
        update(...);
 Log.d(LOG_TAG, "Update was successfully completed");
 ...
    }
    catch (Exception e) {
        Log.e(LOG_TAG, "Update failed.", e);
    
 if (areWeOnline(this)) {
     Log.d(LOG_TAG, "Scheduling an alarm for retrying the update...");

            retries++;

            Editor editor = sharedPreferences.edit();
            editor.putBoolean(Constants.PREFERENCE_COLLECTOR_RETRYING, true);
            editor.putInt(Constants.PREFERENCE_COLLECTOR_RETRIES, retries);
            editor.commit();

            long interval = Constants.COLLECTOR_MIN_RETRY_INTERVAL * retries;
            if (interval > Constants.COLLECTOR_MAX_REFRESH_INTERVAL) {
                interval = Constants.COLLECTOR_MAX_REFRESH_INTERVAL;
            }                     
  
            Intent intent = new Intent(context, MarketCollectionReceiver.class);
            intent.setAction(Constants.SCHEDULE_RETRY);
            PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, 
                intent, 
                PendingIntent.FLAG_UPDATE_CURRENT);
     
            ((AlarmManager) getSystemService(Context.ALARM_SERVICE))
                .set(AlarmManager.RTC, 
                    System.currentTimeMillis() + interval, 
                    pendingIntent);
        }
 else {
     Log.d(LOG_TAG, "We are not online.");
     
            Editor editor = sharedPreferences.edit();
     editor.putBoolean(
                Constants.PREFERENCE_STATUS_WAITING_FOR_CONNECTIVITY, true);
            editor.commit();
     
            ComponentName componentName = 
                new ComponentName(this, ConnectivityBroadcastReceiver.class);
            getPackageManager().setComponentEnabledSetting(
                componentName, 
                PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
                PackageManager.DONT_KILL_APP);
        }
    }
} 
First, note the retries variable. Every time an error occurs, we increment a counter and save it in our preferences. The next update will use this value to delay every update and avoid retries when the server is continuously failing. Note how in this case we set up the alarm with a RETRY action. In case we are online when the error occurs, then we set the flag WAITING_FOR_CONNECTIVITY and enable our ConnectivityBroadcastReceiver to get notified as soon as we are back online.

The other bit of code to pay attention is the flag areWeUpdatingOnlyOneTicker. Although I didn't post the section where this flag gets initialized, it means than the user added a new ticker to the dashboard and we need to run an update just for it.

It's a lot, I know

Yes, it gets tricky, verbose, and it's easy to loose track of every component. By reading back what I just wrote I'm realizing how painful is for developers to take care of so many details. Some day this will get done for us under the hood, or we won't need to worry anymore when technology gets to a point where battery and connectivity are not longer a concern. Unfortunately we are not there yet, and this topic is really important if we want to develop an application that doesn't kill our devices.

Read closely. Try for yourself, and ask if you get lost.

No comments:

Post a Comment