JobScheduler

AlarmManager was our original solution for doing work on a periodic basis. However, AlarmManager can readily be misused, in ways that impact the battery — this is why API Level 19 put renewed emphasis on “inexact” alarm schedules. Worse, AlarmManager will give us control at points in time that may be useless to us, such as giving us control when there is no Internet access, when the point of the scheduled work is to transfer some data over the Internet.

Android 5.0 introduced JobScheduler, which offers a more sophisticated API for handling these sorts of scenarios. This chapter will explore how to set up JobScheduler and use it for one-off and periodic work.

Prerequisites

Understanding this chapter requires that you have read the core chapters, particularly the chapter on AlarmManager. Also, you should have read the chapter on PowerManager and wakelocks.

The Limitations of AlarmManager

AlarmManager does its job, and frequently does it well. However, it is far from perfect:

And so on. AlarmManager is nice, but it would be better to have another solution.

Enter the JobScheduler

JobScheduler was designed to handle those four problems outlined above:

Employing JobScheduler

The JobScheduler/PowerHungry sample project demonstrates the use of JobScheduler, by way of comparing its use to that of AlarmManager.

The UI for JobScheduler allows you to pick from three types of event schedules: exact alarm, inexact alarm, and JobScheduler. You can also choose from one of four polling periods: 1 minute, 15 minutes, 30 minutes, and 60 minutes:

PowerHungry Demo, As Initially Launched
Figure 825: PowerHungry Demo, As Initially Launched

A Switch allows you to determine whether you are simply getting control at those points in time to just log to Logcat, or whether you are going to try to do some work at those points in time. Specifically, the “work” is to download a file, using HttpUrlConnection.

The bottom Switch toggles on and off the event schedules. When the event schedules are toggled on, you cannot manipulate the rest of the UI — you need to turn off the events in order to change the event configuration.

Note that none of this information is persisted. This is a lightweight demo; it is expected that you are keeping this UI in the foreground while a test is running.

Defining and Scheduling the Job

The “job” is defined as an instance of JobInfo, typically created using an instance of JobInfo.Builder to configure a JobInfo using a fluent builder-style API. We teach the JobInfo the work to do and when to do it, then use a JobScheduler to actually schedule the job.

In the sample app, this work is mostly accomplished via a manageJobScheduler() method on the MainActivity class:

  private void manageJobScheduler(boolean start) {
    if (start) {
      JobInfo.Builder b=new JobInfo.Builder(JOB_ID,
          new ComponentName(this, DemoJobService.class));
      PersistableBundle pb=new PersistableBundle();

      if (download.isChecked()) {
        pb.putBoolean(KEY_DOWNLOAD, true);
        b.setExtras(pb).setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
      } else {
        b.setRequiredNetworkType(JobInfo.NETWORK_TYPE_NONE);
      }

      b.setPeriodic(getPeriod()).setPersisted(false)
          .setRequiresCharging(false).setRequiresDeviceIdle(true);

      jobs.schedule(b.build());
    }
    else {
      jobs.cancel(JOB_ID);
    }
  }
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/MainActivity.java)

The start parameter to manageJobScheduler() is driven by the bottom Switch widget. A start value of true means that we should start up the job; a value of false means that we should cancel any existing job.

If start is true, we begin by creating a JobInfo.Builder, supplying two key pieces of data:

The primary way of passing data from the scheduling code (our activity) and the job-implementing code (JobService) is by means of a PersistableBundle – a Bundle-like object that can be persisted to disk. PersistableBundle was introduced in API Level 21, but at that time it inexplicably lacked support for boolean values. API Level 22 added getBoolean() and putBoolean() to PersistableBundle, and this sample project has minSdkVersion of 22 to be able to take advantage of it. If you wanted to use this sample on API Level 21, you would need to convert the boolean into something else, such as 0 and 1 int values.

Our PersistableBundle can have more data than just this one extra, though that is all we need in this case. We attach the PersistableBundle to the JobInfo via the setExtras() method on the JobInfo.Builder.

We can also call methods on the JobInfo.Builder to configure the criteria that should be satisfied before giving us control. In our case, one criterion that we need is to have a network connection, but only if we are supposed to be downloading a file. So, we call setRequiredNetworkType() in either case, indicating that we either want ANY type of network connection (metered or unmetered) or NONE.

Other criteria-defining methods that we invoke include setRequiresCharging() (set to false to indicate we want control even if we are on battery) and setRequiresDeviceIdle() (set to true to indicate that we want control only if the user is not using it).

In the case of this sample, we want to do this work every so often, based upon the period chosen by the user in the bottom Spinner and retrieved via the getPeriod() method. So, we call setPeriodic() on the JobInfo.Builder to request getting control with that frequency, bearing in mind that this is merely a hint, not a requirement, and we may get control more or less frequently than this.

We also call setPersisted(false) to indicate that we do not need for this job to be persisted, so it will be lost on a reboot. If we instead called setPersisted(true), the manifest would need to request the RECEIVE_BOOT_COMPLETED permission to have the job be re-created at boot time.

Finally, we call schedule() on a JobScheduler instance named jobs to schedule the job.

The jobs data member is populated up in onReady() of the activity, where onReady() is a callback from the AbstractPermissionActivity:

    jobs=(JobScheduler)getSystemService(JOB_SCHEDULER_SERVICE);
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/MainActivity.java)

AbstractPermissionActivity, seen elsewhere in this book, handles requesting the runtime permission for external storage that we are going to need later on.

If the start parameter to manageJobScheduler() is false, we call cancel() on the JobScheduler, passing in our unique job ID (JOB_ID) to indicate what job to cancel. Or, we could have called cancelAll(), which would cancel all jobs scheduled by our application.

Implementing the Job

The work for the job itself is handled by a JobService. This is a subclass of Service that we, in turn, extend ourselves, overriding two job-specific callback methods to actually do the work: onStartJob() and onStopJob().

The JobService in our sample app is DemoJobService:

package com.commonsware.android.job;

import android.app.job.JobParameters;
import android.app.job.JobService;
import android.os.PersistableBundle;
import android.util.Log;

public class DemoJobService extends JobService {
  private volatile Thread job=null;

  @Override
  public boolean onStartJob(JobParameters params) {
    PersistableBundle pb=params.getExtras();

    if (pb.getBoolean(MainActivity.KEY_DOWNLOAD, false)) {
      job=new DownloadThread(params);
      job.start();

      return(true);
    }

    Log.d(getClass().getSimpleName(), "job invoked");

    return(false);
  }

  @Override
  synchronized public boolean onStopJob(JobParameters params) {
    if (job!=null) {
      Log.d(getClass().getSimpleName(), "job interrupted");
      job.interrupt();
    }

    return(false);
  }

  synchronized private void clearJob() {
    job=null;
  }

  private class DownloadThread extends Thread {
    private final JobParameters params;

    DownloadThread(JobParameters params) {
      this.params=params;
    }

    @Override
    public void run() {
      Log.d(getClass().getSimpleName(), "job begins");
      new DownloadJob().run();
      Log.d(getClass().getSimpleName(), "job ends");
      clearJob();
      jobFinished(params, false);
    }
  }
}
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/DemoJobService.java)

onStartJob() is passed a JobParameters. This serves both as a “handle” identifying a particular job invocation and giving us access to the job ID (getJobId()) and PersistableBundle of extras (getExtras()) that were set up by our JobInfo when we scheduled the job.

onStartJob() needs to return true if we have successfully forked a background thread to do the work, or false if no work needs to be done. In our case, this is determined by whether or not we want to try to download a file. In a production-grade app, this may be determined by whether there is any work to be done (e.g., “do we have entries in the upload queue?”).

In onStartJob(), we check the PersistableBundle to see if we are supposed to download a file. If we are, we fork a DownloadThread to do that work, then return true. Otherwise, we return false.

Because this sample app illustrates the difference in behavior between JobScheduler and AlarmService, we want to isolate the actual download-the-file logic into a common implementation that can be used from either code path. That takes the form of a DownloadJob, which implements Runnable and does the download work when it is run():

package com.commonsware.android.job;

import android.net.Uri;
import android.os.Environment;
import android.util.Log;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;

class DownloadJob implements Runnable {
  static final Uri TO_DOWNLOAD=
      Uri.parse("https://commonsware.com/Android/excerpt.pdf");

  @Override
  public void run() {
    try {
      File root=Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);

      root.mkdirs();

      File output=new File(root, TO_DOWNLOAD.getLastPathSegment());

      if (output.exists()) {
        output.delete();
      }

      URL url=new URL(TO_DOWNLOAD.toString());
      HttpURLConnection c=(HttpURLConnection)url.openConnection();

      FileOutputStream fos=new FileOutputStream(output.getPath());
      BufferedOutputStream out=new BufferedOutputStream(fos);

      try {
        InputStream in=c.getInputStream();
        byte[] buffer=new byte[8192];
        int len=0;

        while ((len=in.read(buffer)) >= 0) {
          out.write(buffer, 0, len);
        }

        out.flush();
      }
      finally {
        fos.getFD().sync();
        out.close();
        c.disconnect();
      }
    }
    catch (IOException e2) {
      Log.e("DownloadJob", "Exception in download", e2);
    }
  }
}
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/DownloadJob.java)

DownloadThread delegates to DownloadJob to do the actual work. However, when the work is complete, it then calls jobFinished() on the DemoJobService. jobFinished(), as the name suggests, tells the framework that we are finished doing the work associated with this job. If the job succeeded, we pass false as the second parameter, to indicate that this job does not need to be rescheduled. If, on the other hand, we were unable to actually do the work (e.g., we cannot connect to the desired server, perhaps due to server maintenance), we would pass true as the second parameter, to request that this job be rescheduled to be invoked again shortly, so that we can retry the operation.

Our onStopJob() method will be called by Android if environmental conditions have changed and we should stop the background work that we are doing. For example, we asked to do this work when the device was idle — if the user picks up the device and starts using it, we should stop our background work. In this case, if the job thread is still outstanding, we interrupt() it. onStopJob() should return true if this job is still needed and should be retried, or false otherwise. Most short-period periodic jobs should return false, to just worry about the next job in the next period, and that is what onStopJob() does here. One-time jobs, or jobs with long periods (e.g., a day), may wish to return true to ensure that they will get another chance to do the desired work. We will cover more about this issue later in this chapter.

Wiring in the Job Service

Since a JobService is a Service, we need the corresponding <service> element in the manifest. For a JobService, the <service> element is perfectly normal… with one exception:

    <service
      android:name=".DemoJobService"
      android:permission="android.permission.BIND_JOB_SERVICE" />
(from JobScheduler/PowerHungry/app/src/main/AndroidManifest.xml)

You need to defend the service with the BIND_JOB_SERVICE permission. This only allows code that holds the BIND_JOB_SERVICE permission to start or bind to this service, which should limit it to the OS itself.

The Rest of the Sample

As noted earlier, the UI for our activity is a pair of Spinner widgets, along with a pair of Switch widgets:

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

<GridLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="8dp"
    android:useDefaultMargins="true">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/type_label"
        android:layout_row="0"
        android:layout_column="0"/>

    <Spinner
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/type"
        android:layout_row="0"
        android:layout_column="1"/>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/period_label"
        android:layout_row="1"
        android:layout_column="0"/>

    <Spinner
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/period"
        android:layout_row="1"
        android:layout_column="1"/>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/download_label"
        android:layout_row="2"
        android:layout_column="0"/>

    <Switch
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/download"
        android:layout_row="2"
        android:layout_column="1"/>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/scheduled_label"
        android:layout_row="3"
        android:layout_column="0"/>

    <Switch
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/scheduled"
        android:layout_row="3"
        android:layout_column="1"/>
</GridLayout>
(from JobScheduler/PowerHungry/app/src/main/res/layout/main.xml)

onReady() of MainActivity sets up the UI, including populating the two Spinner widgets based on <string-array> resources and hooking up the activity to respond to changes in the checked state of the scheduled Switch widget:

  @SuppressWarnings("ResourceType")
  @Override
  public void onReady(Bundle savedInstanceState) {
    setContentView(R.layout.main);
    type=findViewById(R.id.type);

    ArrayAdapter<String> types=
        new ArrayAdapter<String>(this,
            android.R.layout.simple_spinner_item,
            getResources().getStringArray(R.array.types));

    types.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
    type.setAdapter(types);

    period=findViewById(R.id.period);

    ArrayAdapter<String> periods=
        new ArrayAdapter<String>(this,
            android.R.layout.simple_spinner_item,
            getResources().getStringArray(R.array.periods));

    periods.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
    period.setAdapter(periods);

    download=findViewById(R.id.download);
    scheduled=findViewById(R.id.scheduled);
    scheduled.setOnCheckedChangeListener(this);

    alarms=(AlarmManager)getSystemService(ALARM_SERVICE);
    jobs=(JobScheduler)getSystemService(JOB_SCHEDULER_SERVICE);
  }
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/MainActivity.java)

When the user toggles the scheduled Switch widget, we examine the type Spinner and route control to a method dedicated for handling that particular type of periodic request, such as the manageJobScheduler() method we saw earlier in this chapter:

  private void manageJobScheduler(boolean start) {
    if (start) {
      JobInfo.Builder b=new JobInfo.Builder(JOB_ID,
          new ComponentName(this, DemoJobService.class));
      PersistableBundle pb=new PersistableBundle();

      if (download.isChecked()) {
        pb.putBoolean(KEY_DOWNLOAD, true);
        b.setExtras(pb).setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
      } else {
        b.setRequiredNetworkType(JobInfo.NETWORK_TYPE_NONE);
      }

      b.setPeriodic(getPeriod()).setPersisted(false)
          .setRequiresCharging(false).setRequiresDeviceIdle(true);

      jobs.schedule(b.build());
    }
    else {
      jobs.cancel(JOB_ID);
    }
  }
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/MainActivity.java)

Our onCheckedChanged() for the schedule Switch also calls a toggleWidgets() method that enables or disables the other widgets, depending upon whether the schedule Switch is checked or unchecked:

  private void toggleWidgets(boolean enable) {
    type.setEnabled(enable);
    period.setEnabled(enable);
    download.setEnabled(enable);
  }

  private void manageExact(boolean start) {
    if (start) {
      long period=getPeriod();

      PollReceiver.scheduleExactAlarm(this, alarms, period,
          download.isChecked());
    }
    else {
      PollReceiver.cancelAlarm(this, alarms);
    }
  }

  private void manageInexact(boolean start) {
    if (start) {
      long period=getPeriod();

      PollReceiver.scheduleInexactAlarm(this, alarms, period,
          download.isChecked());
    }
    else {
      PollReceiver.cancelAlarm(this, alarms);
    }
  }

  private void manageJobScheduler(boolean start) {
    if (start) {
      JobInfo.Builder b=new JobInfo.Builder(JOB_ID,
          new ComponentName(this, DemoJobService.class));
      PersistableBundle pb=new PersistableBundle();

      if (download.isChecked()) {
        pb.putBoolean(KEY_DOWNLOAD, true);
        b.setExtras(pb).setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
      } else {
        b.setRequiredNetworkType(JobInfo.NETWORK_TYPE_NONE);
      }

      b.setPeriodic(getPeriod()).setPersisted(false)
          .setRequiresCharging(false).setRequiresDeviceIdle(true);

      jobs.schedule(b.build());
    }
    else {
      jobs.cancel(JOB_ID);
    }
  }

  private long getPeriod() {
    return(PERIODS[period.getSelectedItemPosition()]);
  }
}
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/MainActivity.java)

If the user had chosen an exact alarm, onCheckedChanged() routes control to manageExact():

  private void manageExact(boolean start) {
    if (start) {
      long period=getPeriod();

      PollReceiver.scheduleExactAlarm(this, alarms, period,
          download.isChecked());
    }
    else {
      PollReceiver.cancelAlarm(this, alarms);
    }
  }
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/MainActivity.java)

It, in turn, routes control over to a PollReceiver, a BroadcastReceiver that is set up for handling our alarms:

package com.commonsware.android.job;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.SystemClock;

public class PollReceiver extends BroadcastReceiver {
  static final String EXTRA_PERIOD="period";
  static final String EXTRA_IS_DOWNLOAD="isDownload";

  @Override
  public void onReceive(Context ctxt, Intent i) {
    boolean isDownload=i.getBooleanExtra(EXTRA_IS_DOWNLOAD, false);

    DemoScheduledService.enqueueWork(ctxt,
        new Intent(ctxt, DemoScheduledService.class)
            .putExtra(EXTRA_IS_DOWNLOAD, isDownload));

    long period=i.getLongExtra(EXTRA_PERIOD, -1);

    if (period>0) {
      scheduleExactAlarm(ctxt,
          (AlarmManager)ctxt.getSystemService(Context.ALARM_SERVICE),
          period, isDownload);
    }
  }

  static void scheduleExactAlarm(Context ctxt, AlarmManager alarms,
                                 long period, boolean isDownload) {
    Intent i=new Intent(ctxt, PollReceiver.class)
        .putExtra(EXTRA_PERIOD, period)
        .putExtra(EXTRA_IS_DOWNLOAD, isDownload);
    PendingIntent pi=PendingIntent.getBroadcast(ctxt, 0, i, 0);

    alarms.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP,
        SystemClock.elapsedRealtime()+period, pi);
  }

  static void scheduleInexactAlarm(Context ctxt, AlarmManager alarms,
                                   long period, boolean isDownload) {
    Intent i=new Intent(ctxt, PollReceiver.class)
        .putExtra(EXTRA_IS_DOWNLOAD, isDownload);
    PendingIntent pi=PendingIntent.getBroadcast(ctxt, 0, i, 0);

    alarms.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
        SystemClock.elapsedRealtime()+period, period, pi);
  }

  static void cancelAlarm(Context ctxt, AlarmManager alarms) {
    Intent i=new Intent(ctxt, PollReceiver.class);
    PendingIntent pi=PendingIntent.getBroadcast(ctxt, 0, i, 0);

    alarms.cancel(pi);
  }
}
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/PollReceiver.java)

This sample app has a targetSdkVersion of 21. Hence, on Android 5.0 devices — the ones that have JobScheduler, we cannot set up exact repeating alarms. Our only option is to handle the repeating work ourselves.

Hence, scheduleExactAlarm() creates a broadcast PendingIntent, on an Intent pointing at our PollReceiver, with a pair of extras indicating the polling period and whether or not we should be downloading a file. It then uses setExact() on an AlarmManager to schedule a one-off event to occur one polling period from now.

That, in turn, will trigger onReceive() of the PollReceiver. Here, we call startWakefulService() to have our work be done by a DemoScheduledService. In addition, if we have a polling period, that means that this is an exact alarm, and we call scheduleExactAlarm() to set up the next occurrence of this “repeating” event.

DemoScheduledService is simply a JobIntentService wrapper around the DownloadJob that we used with DemoJobService.

cancelAlarm() on PollReceiver — called by manageExact() when we are stopping the repeating event — creates an equivalent PendingIntent to the ones used for the AlarmManager events, and uses that with cancel() on AlarmManager to cancel those events.

If the user had chosen an inexact alarm, onCheckedChanged() routes control to manageInexact():

  private void manageInexact(boolean start) {
    if (start) {
      long period=getPeriod();

      PollReceiver.scheduleInexactAlarm(this, alarms, period,
          download.isChecked());
    }
    else {
      PollReceiver.cancelAlarm(this, alarms);
    }
  }
(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/MainActivity.java)

It uses the same recipe as manageExact(), except that it calls scheduleInexactAlarm() on PollReceiver. scheduleInexactAlarm(), in turn, uses setInexactRepeating() on AlarmManager to arrange to get control every so often.

Pondering Backoff Criteria

Sometimes, even with the Internet-availability checks offered by JobScheduler, you find that you cannot actually do the job you scheduled. Perhaps the server is down for maintenance, or has been replaced by a honeycomb frame, or something. In this case, while you failed to do the job now, you may want to try again later.

Sometimes, “later” can just be handled by your existing JobScheduler setup. If the job in question is a periodic job, and missing a whole period is not a big problem, you might just continue on normally.

However, sometimes you will want the job to be retried, either because:

Requesting that a job be retried is handled by the boolean parameter to jobFinished() or the boolean return value from onStopJob(). true means that you want the job to be rescheduled; false means that it is OK to skip the job entirely.

Given that you use true for either jobFinished() or onStopJob(), there are three possible options for how to request and retry a failed job:

Idle Jobs

If you requested idle-only jobs, if the user wakes up the device while the job is going on, you will be called with onStopJob(). Ideally, you then stop the background work and return true or false from onStopJob() to determine if the job should be rescheduled.

If you request a job be rescheduled, when that job is set up to only run when the device is idle, the job is simply “put back in the queue” to be tried again during the next idle window.

Default Behavior

If, for a non-idle-only job, you use true for jobFinished() or onStopJob(), the next time to try will be calculated using the default backoff criteria, which has a time of 30 seconds and a policy of BACKOFF_POLICY_EXPONENTIAL.

What this means is that the first time you use true, your job will be tried again 30 seconds later. If you use true again for that job, it will be tried again 60 seconds later. If you use true again, it would be tried 120 seconds later — in other words, each job failure will reschedule using the formula 2n-1t, where n is the number of failures and t is 30 seconds.

However, there is a cap of 18,000,000 milliseconds, or what normal people would refer to as “5 hours”. That is the most your job will be delayed, regardless of how many failures you have.

Custom Backoff Criteria

You can change the backoff criteria for non-idle-only jobs via a call to setBackoffCriteria() on your JobInfo.Builder, where you provide your own time (measured in milliseconds) and policy (BACKOFF_POLICY_EXPONENTIAL or BACKOFF_POLICY_LINEAR).

As noted above, the formula for exponential backoff rescheduling is 2n-1t, where n is the number of failures and t is your chosen time.

The formula for linear backoff rescheduling is n*t, where n is the number of failures and t is your chosen time.

Other JobScheduler Features

There are a few other options for scheduling jobs that may be of use to you in select circumstances:

Also, JobScheduler has a getAllPendingJobs() method, that returns a List of JobInfo objects representing “the jobs registered by this package that have not yet been executed”. Presumably, this includes the next occurrence of any periodic jobs and any jobs that are blocked pending a backoff delay, though the documentation is unclear on this point.

JobScheduler Period Limits

As of Android 7.0, JobScheduler does not support jobs running more frequently than once every 15 minutes.

GcmNetworkManager

As noted earlier in this chapter, Android 5.0 added JobScheduler. However, Google did not release any sort of backport of this, as that would be difficult to do on a whole-device basis. They did not even implement a JobSchedulerCompat, hampering adoption.

Firebase now has a GcmNetworkManager that, despite the name, is basically a backport of JobScheduler. In fact, it will delegate to JobScheduler on Android 5.0+ devices. It is unclear how old of an Android OS version GcmNetworkManager supports, but it is likely to work on more devices than does JobScheduler.

However, it does introduce a tie to Google Play Services, which will not be appropriate for all apps.

Periodic Work, Across Device Versions

Of course, all of this is a pain. And, where there is pain, somebody eventually creates a library to try to ease that pain.

Evernote — the NSaaS (note storage as a service) provider — has released android-job, a library that offers a single API that uses GcmNetworkManager (if you opt into it), JobScheduler (on API Level 21+, for inexact jobs), or AlarmManager (for API Level 19 and below, plus for exact jobs). This library can simplify your code, by handling the version-specific logic for you.

The JobScheduler/Dispatcher sample project demonstrates the use of android-job. It is based upon the PowerHungry sample app, adding in a new option for using this new library.

The Dependency

Evernote publishes android-job as an artifact, so adding it to your project is as simple as a single line in your build.gradle file:

dependencies {
    implementation 'com.android.support:support-v13:27.0.2'
    implementation 'com.evernote:android-job:1.2.1'
}
(from JobScheduler/Dispatcher/app/build.gradle)

The Job

With AlarmManager, usually your periodic work is handled by a combination of a WakefulBroadcastReceiver and an IntentService. With JobScheduler, your work is handled by a JobService. With android-job, your work is handled by custom subclasses of a library-supplied Job class.

Your Job subclass needs to override onRunJob(). Akin to onStartJob() of a JobService, you get a Params object that you can use to identify the details of this specific job. Based on the library’s implementation and JavaDocs, you should do the work for your job directly in onRunJob(), returning one of three values:

The sample app has a DemoUnifiedJob that handles all of this:

package com.commonsware.android.job;

import android.support.annotation.NonNull;
import android.util.Log;
import com.evernote.android.job.Job;

public class DemoUnifiedJob extends Job {
  static final String JOB_TAG=
    DemoUnifiedJob.class.getCanonicalName();

  @NonNull
  @Override
  protected Result onRunJob(Params params) {
    Log.d(getClass().getSimpleName(), "scheduled unified work begins");

    if (getParams()
          .getExtras()
          .getBoolean(PollReceiver.EXTRA_IS_DOWNLOAD, false)) {
      new DownloadJob().run();  // do synchronously, as we are on
                                // a background thread already
    }

    Log.d(getClass().getSimpleName(), "scheduled unified work ends");

    return(Result.SUCCESS);
  }
}
(from JobScheduler/Dispatcher/app/src/main/java/com/commonsware/android/job/DemoUnifiedJob.java)

As will be seen later when we schedule this work, we add in our EXTRA_IS_DOWNLOAD boolean value, akin to how we handled this with JobScheduler, to know whether or not we are supposed to download the file or not.

The JobCreator

While executing jobs is similar to JobScheduler (just simpler), actually setting up jobs is quite a bit more cumbersome.

The first step towards setting up jobs is to create a JobCreator. This class is simply a way to tie a String to a Job subclass. In the create() method, given a String, you return an instance of the associated Job subclass, as is done in the sample app’s DemoUnifiedJobCreator:

package com.commonsware.android.job;

import com.evernote.android.job.Job;
import com.evernote.android.job.JobCreator;

public class DemoUnifiedJobCreator implements JobCreator {
  @Override
  public Job create(String tag) {
    if (DemoUnifiedJob.JOB_TAG.equals(tag)) {
        return(new DemoUnifiedJob());
    }

    throw new IllegalArgumentException("Job tag not recognized: "+tag);
  }
}
(from JobScheduler/Dispatcher/app/src/main/java/com/commonsware/android/job/DemoUnifiedJobCreator.java)

The Application

The recommended pattern for setting up android-job is to use a custom Application subclass. It needs to set up a JobManager singleton and register any JobCreator classes that you might have.

The sample app does this in DemoUnifiedApplication:

package com.commonsware.android.job;

import android.app.Application;
import com.evernote.android.job.JobManager;

public class DemoUnifiedApplication extends Application {
  @Override
  public void onCreate() {
    super.onCreate();

    JobManager
      .create(this)
      .addJobCreator(new DemoUnifiedJobCreator());
  }
}
(from JobScheduler/Dispatcher/app/src/main/java/com/commonsware/android/job/DemoUnifiedApplication.java)

That, in turn, is set up as our app’s Application subclass via the android:name attribute on the <application> element in the manifest:

  <application
    android:name=".DemoUnifiedApplication"
    android:icon="@drawable/ic_launcher"
    android:label="@string/app_name">
(from JobScheduler/Dispatcher/app/src/main/AndroidManifest.xml)

Scheduling Jobs

Given all of that prep work, we can now actually schedule jobs to be executed. The flow is very similar to that of JobScheduler:

  private void manageUnified(boolean start) {
    if (start) {
      final JobRequest.Builder b=
        new JobRequest.Builder(DemoUnifiedJob.JOB_TAG);
      PersistableBundleCompat extras=new PersistableBundleCompat();

      if (download.isChecked()) {
        extras.putBoolean(KEY_DOWNLOAD, true);
        b
          .setExtras(extras)
          .setRequiredNetworkType(JobRequest.NetworkType.CONNECTED);
      }
      else {
        b.setRequiredNetworkType(JobRequest.NetworkType.ANY);
      }

      b
        .setPeriodic(getPeriod())
        .setRequiresCharging(false)
        .setRequiresDeviceIdle(true);

      unifiedJobId=b.build().schedule();
    }
    else {
      JobManager.instance().cancel(unifiedJobId);
    }
  }
(from JobScheduler/Dispatcher/app/src/main/java/com/commonsware/android/job/MainActivity.java)

Enabling GcmNetworkManager Support

By default, android-job will use JobScheduler and/or AlarmManager. However, if you add the play-services-gcm dependency to your project (version 9.4.0 or higher), and configure a specific <service> element in your manifest, then android-job will also consider using GcmNetworkManager. Details for this are available in the project documentation.

Android 6.0 and “the War on Background Processing”

Google has been increasingly aggressive about trying to prevent background work, particularly while the device is deemed to be idle, in an effort to improve battery life. In Android 4.4 (API Level 19), we were given a strong “nudge” to use inexact alarms. In Android 5.0 (API Level 21), we were given JobScheduler as a smarter AlarmManager, but one that also emphasizes inexact schedules.

In Android 6.0, Google broke out more serious weaponry in the war against background work, in ways that are going to cause a fair bit of pain and confusion for users.

Doze Mode

If the device’s screen is off, the device is not being charged, and the device does not appear to be moving (as determined via sensors, like the accelerometer), an Android 6.0+ device will go into “Doze mode”. This mode is reminiscent of similar modes used by specific device manufacturers, such as SONY’s STAMINA mode.

While in “Doze mode”, your scheduled alarms (with AlarmManager), jobs (with JobScheduler), and syncs (with SyncManager) will be ignored by default, except during occasional “idle maintenance windows”. In short, much of what your user thinks will happen in the background will not happen.

App Standby Mode

Further compounding the problem from “Doze mode” is “app standby”.

After some undefined period of time, an app that has not been in the foreground (or is showing a Notification) will be put into “standby” state. While the app is in “standby”:

How to Win the War

The vision behind “the war on background processing” is to improve battery life, particularly while the device is not being used (Doze mode) or for apps that are not being used (app standby). However, any number of apps will have their behavior severely compromised by these changes.

Here are some techniques for helping your app behave better on Android 6.0+.

GCM

If you are using Google Cloud Messaging (GCM), and you send a “high-priority tickle” to the app on a device, that may allow you to run then, despite being in Doze mode or app standby mode. However, this implies that you have all the plumbing set up for GCM, that the device has an active network connection, etc. Also, this requires you to adopt GCM, which has its issues (no service-level agreement, Google has access to all of the messages, etc.).

…AndAllowWhileIdle()

AlarmManager now has two additional methods:

These work better in Doze mode and app standby mode, allowing you to get control briefly even if otherwise you would not. However:

Use a Foreground Service

While not officially documented, Dianne Hackborn (a core Android developer) wrote in a comment on a Google+ post:

Apps that have been running foreground services (with the associated notification) are not restricted by doze.

The Whitelist

Users have the ability to disable these “battery optimizations” for an individual app, allowing it to run closer to normally. On the “Apps” screen in Settings, there is now a gear icon in the action bar:

Android 6.0, Settings App, Apps Screen
Figure 826: Android 6.0, Settings App, Apps Screen

Tapping that brings up a “Configure apps” screen. On there is a “Battery optimization” entry. Tapping on that will initially show the apps for which battery optimizations will be ignored (a.k.a., “Not optimized”):

Android 6.0, Settings App, Battery Optimization Screen
Figure 827: Android 6.0, Settings App, Battery Optimization Screen

If the user toggles the “Not optimized” drop-down to “All apps” and taps on one of those apps, the user can elect to decide whether to “optimize” the app (and cause app standby to trigger) or not:

Android 6.0, Settings App, Battery Optimization Options Dialog
Figure 828: Android 6.0, Settings App, Battery Optimization Options Dialog

This “whitelist” of apps allows you to hold wakelocks and access the network. It does not change the behavior of AlarmManager, JobScheduler, or SyncManager — those things will still fire far less frequently in Doze mode or in app standby.

To determine if your app is already on the whitelist, you can call isIgnoringBatteryOptimizations() on a PowerManager instance.

If you would like to lead the user over to the screen where they can generally configure the whitelist, use an ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS Intent with startActivity():


startActivity(new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS));

If you would like to drive the user straight to the screen where they can add your specific app to the whitelist:


  Intent i=new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
                      Uri.parse("package:" + getPackageName()));

  startActivity(intent);

Note, though, that using this may cause your app to be banned on the Play Store, even though it is a legitimate part of the Android SDK.

While the whitelist existed in the first developer preview of Android 6.0, its role was expanded very late in the process, as originally it did not affect Doze mode. The rationale appears to be for apps that cannot use GCM as the trigger mechanism to do background work, particularly if they need something else network-based as the trigger. For example, SIP clients, XMPP clients, MQTT clients, and so on are idle until a message comes in on an open network connection, yet none of those can be readily converted to use GCM. The whitelist allows apps to behave as they did prior to Android 6.0, though it requires user involvement.

However, any app can use this whitelist approach to return to more-normal behavior. The biggest limitation is for apps that relied upon AlarmManager, JobScheduler, or SyncAdapter as their triggers, as those are still crippled, regardless of whitelist status. The best you can get is ~15 minute periods, via setExactAndAllowWhileIdle().

If you are sure that you need polling more frequently than that, and you are sure that the user will value that polling, your primary option is to use a foreground Service (or whitelisted app) and Java’s ScheduledExecutorService to get control every so often, using a partial wakelock to keep the CPU powered on all the time. From a battery standpoint, this is horrible, far worse than the behavior you would get on Android 5.1 and earlier using AlarmManager. But, it’s the ultimate workaround, which is why it is demonstrated in the AlarmManager/AntiDoze sample application.

The AntiDoze sample is based off of the greenrobot’s EventBus sample from the chapter on event bus alternatives. In that app, we used AlarmManager to get control every 15 seconds to either update a fragment (if the UI was in the foreground) or show a Notification (if not). AntiDoze gets rid of the every-event Notification, replacing it with appending an entry to a log file. And, it replaces AlarmManager with ScheduledExecutorService inside of a foreground Service, trying to run forever and get control every 15 seconds along the way.

This app has two product flavors defined in its app/build.gradle file, normal and foreground:

apply plugin: 'com.android.application'

dependencies {
    implementation 'org.greenrobot:eventbus:3.0.0'
    implementation "com.android.support:support-v13:27.0.2"
}

android {
    compileSdkVersion 27
    buildToolsVersion '27.0.3'

    defaultConfig {
        minSdkVersion 15
        targetSdkVersion 27
    }

    flavorDimensions "default"

    productFlavors {
        foreground {
            dimension "default"
            buildConfigField "boolean", "IS_FOREGROUND", "true"
        }

        normal {
            dimension "default"
            buildConfigField "boolean", "IS_FOREGROUND", "false"
        }
    }
}
(from AlarmManager/AntiDoze/app/build.gradle)

A normal build will use a regular Service; a foreground build will use a foreground Service.

The launcher activity is EventDemoActivity. Its onCreate() method will do three things:

  1. If we are on Android 6.0 or higher, it will use isIgnoringBatteryOptimizations() on PowerManager to see if we are already on the battery optimization whitelist, and if not, display a system-supplied dialog-themed activity to ask the user to add our app to the whitelist
  2. If we do not already have the EventLogFragment, add it
  3. If we do not already have the EventLogFragment, also start up the ScheduledService, as probably it is not already running

package com.commonsware.android.antidoze;

import android.support.v4.app.FragmentActivity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.PowerManager;
import android.provider.Settings;

public class EventDemoActivity extends FragmentActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    if (Build.VERSION.SDK_INT>Build.VERSION_CODES.LOLLIPOP_MR1) {
      String pkg=getPackageName();
      PowerManager pm=getSystemService(PowerManager.class);

      if (!pm.isIgnoringBatteryOptimizations(pkg)) {
        Intent i=
          new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
            .setData(Uri.parse("package:"+pkg));

        startActivity(i);
      }
    }

    if (getSupportFragmentManager().findFragmentById(android.R.id.content)==null) {
      getSupportFragmentManager().beginTransaction()
        .add(android.R.id.content,
          new EventLogFragment()).commit();
      startService(new Intent(this, ScheduledService.class));
    }
  }
}
(from AlarmManager/AntiDoze/app/src/main/java/com/commonsware/android/antidoze/EventDemoActivity.java)

To be able to use ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, we need to request and hold the REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission, which we handle in the manifest:

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

  <uses-permission android:name="android.permission.WAKE_LOCK"/>
  <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
  <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />

  <application
    android:icon="@drawable/ic_launcher"
    android:label="@string/app_name"
    android:theme="@android:style/Theme.Holo.Light.DarkActionBar">
    <activity
      android:name="EventDemoActivity"
      android:label="@string/app_name">
      <intent-filter>
        <action android:name="android.intent.action.MAIN"/>

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

    <receiver android:name="PollReceiver">
      <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED"/>
      </intent-filter>
    </receiver>

    <receiver android:name="StopReceiver"/>

    <service android:name="ScheduledService"/>
  </application>

</manifest>
(from AlarmManager/AntiDoze/app/src/main/AndroidManifest.xml)

The rest of the UI layer is unchanged. Where the differences really creep in is with ScheduledService. This used to be a WakefulIntentService, triggered by an alarm event. Now, it is a regular service, designed to run all the time.

As part of initializing the ScheduledService class, we create an instance of ScheduledExecutorService, through the newSingleThreadScheduledExecutor() static method on the Executors utility class:

  private ScheduledExecutorService sched=
    Executors.newSingleThreadScheduledExecutor();
(from AlarmManager/AntiDoze/app/src/main/java/com/commonsware/android/antidoze/ScheduledService.java)

In onCreate(), we:

  @Override
  public void onCreate() {
    super.onCreate();

    PowerManager mgr=(PowerManager)getSystemService(POWER_SERVICE);

    wakeLock=mgr.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
      getClass().getSimpleName());
    wakeLock.acquire();

    if (BuildConfig.IS_FOREGROUND) {
      foregroundify();
    }

    log=new File(getExternalFilesDir(null), "antidoze-log.txt");
    log.getParentFile().mkdirs();
    sched.scheduleAtFixedRate(this, 0, 15, TimeUnit.SECONDS);
  }
(from AlarmManager/AntiDoze/app/src/main/java/com/commonsware/android/antidoze/ScheduledService.java)

We can pass the service itself to scheduleAtFixedRate() because it implements the Runnable interface. Its run() method uses greenrobot’s EventBus to tell the UI layer about our event, plus it calls an append() method to log that event to our log file:

  @Override
  public void run() {
    RandomEvent event=new RandomEvent(rng.nextInt());

    EventBus.getDefault().post(event);
    append(log, event);
  }
(from AlarmManager/AntiDoze/app/src/main/java/com/commonsware/android/antidoze/ScheduledService.java)

append() simply uses Java file I/O to append a line to the log file:

  private void append(File f, RandomEvent event) {
    try {
      FileOutputStream fos=new FileOutputStream(f, true);
      Writer osw=new OutputStreamWriter(fos);

      osw.write(event.when.toString());
      osw.write(" : ");
      osw.write(Integer.toHexString(event.value));
      osw.write('\n');
      osw.flush();
      fos.flush();
      fos.getFD().sync();
      fos.close();

      Log.d(getClass().getSimpleName(),
        "logged to "+f.getAbsolutePath());
    }
    catch (IOException e) {
      Log.e(getClass().getSimpleName(),
        "Exception writing to file", e);
    }
  }
(from AlarmManager/AntiDoze/app/src/main/java/com/commonsware/android/antidoze/ScheduledService.java)

The foregroundify() method, called from onCreate(), creates a Notification and calls startForeground() to make the service be a foreground service:

  private void foregroundify() {
    NotificationManager mgr=
      (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
      mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
        "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
    }

    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER);
    Intent iActivity=new Intent(this, EventDemoActivity.class);
    PendingIntent piActivity=
      PendingIntent.getActivity(this, 0, iActivity, 0);
    Intent iReceiver=new Intent(this, StopReceiver.class);
    PendingIntent piReceiver=
      PendingIntent.getBroadcast(this, 0, iReceiver, 0);

    b.setAutoCancel(true)
      .setDefaults(Notification.DEFAULT_ALL)
      .setContentTitle(getString(R.string.app_name))
      .setContentIntent(piActivity)
      .setSmallIcon(R.drawable.ic_launcher)
      .setTicker(getString(R.string.app_name))
      .addAction(R.drawable.ic_stop_white_24dp,
        getString(R.string.notif_stop),
        piReceiver);

    startForeground(NOTIFY_ID, b.build());
  }
(from AlarmManager/AntiDoze/app/src/main/java/com/commonsware/android/antidoze/ScheduledService.java)

The Notification includes a “stop” action, pointing to a StopReceiver, which just uses stopService() to stop the service. This allows the user to shut down our background service at any point, just via the Notification.

When the service is stopped, onDestroy() tidies things up, notably releasing the wakelock:

  @Override
  public void onDestroy() {
    sched.shutdownNow();
    wakeLock.release();
    stopForeground(true);

    super.onDestroy();
  }
(from AlarmManager/AntiDoze/app/src/main/java/com/commonsware/android/antidoze/ScheduledService.java)

Running this overnight on an Android 6.0 device shows that, indeed, we get control every 15 seconds, as desired. The device’s battery drains commensurately, considering that we are keeping the CPU powered on all of the time. Either the whitelist keeps us going (normal flavor) or the foreground service keeps us going (foreground flavor).

setAlarmClock()

AlarmManager also has a setAlarmClock() method, added in API Level 21. This works a bit like setExact() (and, hence, setExactAndAllowWhileIdle()), in that you provide a time to get control and a PendingIntent to be invoked at that time. From the standpoint of power management, Doze mode leaves setAlarmClock() events alone, and so they are executed at the appropriate time regardless of device state. However, at the same time, setAlarmClock() has some user-visible impacts that make it suitable for certain apps (e.g., calendar reminders) and unsuitable for others (e.g., polling).

Hope Somebody Else Does Something

Doze mode is for the entire device. Hence, your app may wind up getting control more frequently than you might expect, even without any code changes, simply because somebody else is doing something to get control more frequently.

Scheduling Content Monitoring

One long-standing challenge in Android is finding out when content changes in other apps. While ContentObserver is great for this purpose, you have to have a running process for it to work. As a result, some apps try desperately to keep a process running all the time to find out about changes to foreign ContentProviders, tying up system RAM as a result.

JobScheduler, as of Android 7.0, has an option to effectively register a ContentObserver for you. You indicate the Uri to monitor, and it invokes your JobService when the data at that Uri changes. This way, you do not need to keep a process around.

To do that, you create a JobInfo.TriggerContentUri object, identifying what to monitor. You pass that to addTriggerContentUri() on your JobInfo.Builder, and schedule the resulting JobInfo with the JobScheduler as before.

For example, the JobScheduler/Content sample project asks JobScheduler to monitor the ContactsContract provider for new contacts.

MainActivity has virtually nothing to do with any of this, but instead goes through all the work to set up runtime permission access to the READ_CONTACTS permission:

package com.commonsware.android.jobsched.content;

import android.app.Activity;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.view.View;
import android.widget.Toast;
import static android.Manifest.permission.READ_CONTACTS;

public class MainActivity extends Activity {
  private static final String[] PERMS_ALL={
    READ_CONTACTS
  };
  private static final int RESULT_PERMS_INITIAL=1339;
  private static final String STATE_IN_PERMISSION=
    "com.commonsware.android.jobsched.content.inPermission";
  private boolean isInPermission=false;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    if (savedInstanceState!=null) {
      isInPermission=
        savedInstanceState.getBoolean(STATE_IN_PERMISSION, false);
    }

    if (!isInPermission) {
      if (hasPermission(READ_CONTACTS)) {
        configureJob();
      }
      else {
        isInPermission=true;
        ActivityCompat.requestPermissions(this, PERMS_ALL,
          RESULT_PERMS_INITIAL);
      }
    }
  }

  @Override
  protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);

    outState.putBoolean(STATE_IN_PERMISSION, isInPermission);
  }

  @Override
  public void onRequestPermissionsResult(int requestCode,
                                         String[] permissions,
                                         int[] grantResults) {
    boolean sadTrombone=true;

    isInPermission=false;

    if (requestCode==RESULT_PERMS_INITIAL) {
      if (hasPermission(READ_CONTACTS)) {
        configureJob();
        sadTrombone=false;
      }
    }

    if (sadTrombone) {
      Toast.makeText(this, R.string.msg_no_perm,
        Toast.LENGTH_LONG).show();
    }
  }

  private void configureJob() {
    Toast.makeText(this, R.string.msg_add,
      Toast.LENGTH_LONG).show();
    DemoJobService.schedule(this);
    finish();
  }

  private boolean hasPermission(String perm) {
    return(ContextCompat.checkSelfPermission(this, perm)==
      PackageManager.PERMISSION_GRANTED);
  }
}
(from JobScheduler/Content/app/src/main/java/com/commonsware/android/jobsched/content/MainActivity.java)

Eventually, though, if the user agrees to the permission, MainActivity calls a static schedule() method on DemoJobService, to set up the content monitor:

  static void schedule(Context ctxt) {
    ComponentName cn=
      new ComponentName(ctxt, DemoJobService.class);
    JobInfo.TriggerContentUri trigger=
      new JobInfo.TriggerContentUri(CONTENT_URI,
        JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS);
    JobInfo.Builder b=
      new JobInfo.Builder(ME_MYSELF_AND_I, cn)
        .addTriggerContentUri(trigger);
    JobScheduler jobScheduler=
      (JobScheduler)ctxt.getSystemService(Context.JOB_SCHEDULER_SERVICE);

    jobScheduler.schedule(b.build());
  }
(from JobScheduler/Content/app/src/main/java/com/commonsware/android/jobsched/content/DemoJobService.java)

Here, we:

The rest of DemoJobService handles the results, in this case just raising a Notification:

  @Override
  public boolean onStartJob(JobParameters params) {
    NotificationManager mgr=
      (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
      mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
        "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
    }

    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER)
        .setAutoCancel(true)
        .setDefaults(Notification.DEFAULT_ALL)
        .setContentTitle("You added a contact!")
        .setSmallIcon(android.R.drawable.stat_notify_more);

    mgr.notify(NOTIFY_ID, b.build());

    return(false);
  }

  @Override
  synchronized public boolean onStopJob(JobParameters params) {
    return(false);
  }
(from JobScheduler/Content/app/src/main/java/com/commonsware/android/jobsched/content/DemoJobService.java)

However, if we wanted, the JobParameters passed into onStartJob() contains information about what changed.

getTriggeredContentAuthorities() returns a String array of the names of the authorities whose changes triggered this job, if any. It will return null if the job triggered for some other reason, such as a deadline.

If getTriggeredContentAuthorities() returns a non-null value, then you can try calling getTriggeredContentUris() to find out the specific Uri values that changed. However, this may be null, if there were too many changes to report (the limit is ~50).

Note that there are limitations on these content-monitoring jobs:

One problem with monitoring content for changes is that those changes may occur too frequently. In Android 7.0, you have two new JobInfo.Builder methods that you can use to manage this:

JobScheduler as Work Queue

Android 8.0 adds a work queue mode to JobScheduler. IntentService had such a queue, after a fashion, in that it would process one Intent at a time through onHandleIntent(), queuing up other Intent objects that arrive while onHandleIntent() is busy. JobScheduler now offers a similar capability for your JobService, where you can post jobs and have your JobService end when the work is completed.

The JobScheduler/WorkQueue sample project illustrates this. We have a JobService that will download files as its “work”, posting the results on an event bus. Our client is an instrumentation test that will confirm that the downloads work as expected.

Defining Some “Work”

The “work” to be enqueued comes in the form of a JobWorkItem, which is a thin wrapper around an Intent. That Intent is used for payload, such as filling in extras. That Intent is not used for actually starting or binding to a service.

The sample project has a WorkService which is the JobService for handling these jobs. It has a static buildWorkItem() method that will help create a JobWorkItem from the two pieces of data that we want as our “work”:

  public static JobWorkItem buildWorkItem(int workIndex, String url) {
    Intent i=new Intent();

    i.setData(Uri.parse(url));
    i.putExtra(EXTRA_WORK_INDEX, workIndex);

    return(new JobWorkItem(i));
  }
(from JobScheduler/WorkQueue/app/src/main/java/com/commonsware/android/job/work/WorkService.java)

Enqueuing the Work

JobScheduler in Android 8.0 has an enqueue() method. It takes a JobInfo, the way you normally schedule jobs, along with a JobWorkItem. It arranges to start the JobService if it is not already running and adds the JobWorkItem to its work queue.

One strong recommendation outlined in the documentation is to try to use the same (or an equivalent) JobInfo object for each enqueue() call. Changing the job characteristics — in particular, tightening constraints, like now needing to be on a charger — will cause Android to have to stop your running JobService (if it is running) and restart it, perhaps later.

WorkService has a static enqueueWork() method that handles all of the details:

  public static JobInfo enqueueWork(Context ctxt, JobInfo jobInfo, List<JobWorkItem> work) {
    JobScheduler jobScheduler=ctxt.getSystemService(JobScheduler.class);

    if (jobInfo==null) {
      ComponentName cn=new ComponentName(ctxt, WorkService.class);

      jobInfo=new JobInfo.Builder(JOB_ID, cn)
        .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
        .build();
    }

    for (JobWorkItem item : work) {
      jobScheduler.enqueue(jobInfo, item);
    }

    return(jobInfo);
  }
(from JobScheduler/WorkQueue/app/src/main/java/com/commonsware/android/job/work/WorkService.java)

enqueueWork() takes three parameters:

enqueueWork() will create a JobInfo object if needed, but otherwise it will reuse the passed-in JobInfo. That JobInfo requires a network connection, as we will be downloading a file, but otherwise sets no constraints. Then, enqueueWork() simply iterates over the JobWorkItem objects and calls enqueue() to register each of them. enqueueWork() returns the JobInfo object that we created or used, for later reuse.

Working Off the Queue

As with any JobService, onStartJob() is our entry point for doing the work requested by whoever scheduled the job. In this case, it delegates the real work to a scheduleWork() method, then returns true to indicate that the work is ongoing.

  @Override
  public boolean onStartJob(JobParameters params) {
    scheduleWork(params);

    return(true);
  }
(from JobScheduler/WorkQueue/app/src/main/java/com/commonsware/android/job/work/WorkService.java)

scheduleWork(), in turn, calls dequeueWork() on the JobParameters, until it returns null to indicate that there is no more queued work:

  private void scheduleWork(final JobParameters params) {
    if (!threadPool.isShutdown()) {
      JobWorkItem item;

      while ((item=params.dequeueWork())!=null) {
        final int workIndex=item.getIntent().getIntExtra(EXTRA_WORK_INDEX, -1);
        final String url=item.getIntent().getData().toString();
        final JobWorkItem itemToDo=item;

        threadPool.execute(new Runnable() {
          @Override
          public void run() {
            download(workIndex, url);
            params.completeWork(itemToDo);
            scheduleWork(params);
          }
        });
      }
    }
  }
(from JobScheduler/WorkQueue/app/src/main/java/com/commonsware/android/job/work/WorkService.java)

dequeueWork() returns a JobWorkItem object if there is one. We can retrieve values out of it and arrange for a ThreadPoolExecutor to perform the actual work. Remember: onStartJob() is called on the main application thread, so you cannot process the work directly in most cases.

That ThreadPoolExecutor is a simple field on the WorkService, initialized via Executors.newFixedThreadPool():

  private ExecutorService threadPool=Executors.newFixedThreadPool(3);
(from JobScheduler/WorkQueue/app/src/main/java/com/commonsware/android/job/work/WorkService.java)

For each JobWorkItem, the ThreadPoolExecutor does three things on a background thread:

  1. It calls a download() method to download the file identified by the supplied URL
  2. It calls completeWork() on the JobParameters, to indicate that this work item is complete and can be removed from the queue
  3. It calls scheduleWork() again, in case more jobs arrived while we were busy downloading the file

The result is that we keep calling scheduleWork() until we are out of JobWorkItem instances to process. At that point, the final dequeueWork() call will not only return null but also arrange to shut down our JobService. In particular, we do not call jobFinished() to do that ourselves.

download() uses OkHttp to download the content identified by the URL, then uses Okio’s HashingSource to compute the SHA-256 hash of the content:

  private void download(int workIndex, String url) {
    try {
      Response response=ok.newCall(new Request.Builder().url(url).build()).execute();
      HashingSource hashingSource=HashingSource.sha256(response.body().source());

      EventBus.getDefault().post(new Result(hashingSource.hash(), workIndex, null));
    }
    catch (IOException e) {
      Log.e(getClass().getSimpleName(), "Exception from OkHttp", e);
      EventBus.getDefault().post(new Result(null, workIndex, e));
    }
  }
(from JobScheduler/WorkQueue/app/src/main/java/com/commonsware/android/job/work/WorkService.java)

download() posts the results — either the hash or an Exception, along with the int identifying this piece of work — on an event bus, wrapped in a Result object:

  public static class Result {
    public final ByteString hash;
    public final int workIndex;
    public final Exception e;

    Result(ByteString hash, int workIndex, Exception e) {
      this.hash=hash;
      this.workIndex=workIndex;
      this.e=e;
    }
  }
(from JobScheduler/WorkQueue/app/src/main/java/com/commonsware/android/job/work/WorkService.java)

However, it is possible that while our downloads are going on, that we lose connectivity. Not only will OkHttp start throwing errors, but JobScheduler will trigger a call to onStopJob() on our WorkService. There, we just shutdown() the thread pool, as we do when all of the work is done:

  @Override
  public boolean onStopJob(JobParameters params) {
    threadPool.shutdown();

    return(true);
  }
(from JobScheduler/WorkQueue/app/src/main/java/com/commonsware/android/job/work/WorkService.java)

Testing the Service

The sample project has no significant UI — the MainActivity just shows a Toast telling you to run the instrumentation tests. There, you will find a WorkTests class that:

@RunWith(AndroidJUnit4.class)
public class WorkTests {
  private static final String URL=
    "https://commonsware.com/Android/Android-1_0-CC.pdf";
  private static final String EXPECTED_HASH_HEX=
    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
  private CountDownLatch latch;
  private Exception e=null;
  private HashSet<Integer> workIndices=new HashSet<>();

  @Test
  public void testWork() throws Exception {
    Random r=new Random();
    int firstBatchCount=4+r.nextInt(4);
    int secondBatchCount=4+r.nextInt(4);

    latch=new CountDownLatch(firstBatchCount+secondBatchCount);

    EventSink sink=new EventSink();

    EventBus.getDefault().register(sink);

    try {
      JobInfo jobInfo=null;
      ArrayList<JobWorkItem> items=new ArrayList<>();

      for (int i=0;i<firstBatchCount;i++) {
        items.add(WorkService.buildWorkItem(i, URL));
      }

      jobInfo=WorkService.enqueueWork(InstrumentationRegistry.getTargetContext(),
        jobInfo, items);

      SystemClock.sleep(1000);

      items.clear();

      for (int i=0;i<secondBatchCount;i++) {
        items.add(WorkService.buildWorkItem(i+firstBatchCount, URL));
      }

      WorkService.enqueueWork(InstrumentationRegistry.getTargetContext(),
        jobInfo, items);

      latch.await(firstBatchCount+secondBatchCount, TimeUnit.SECONDS);

      if (e!=null) {
        throw e;
      }
    }
    finally {
      EventBus.getDefault().unregister(sink);
    }

    assertEquals(firstBatchCount+secondBatchCount, workIndices.size());
  }

  private class EventSink {
    @Subscribe(threadMode =ThreadMode.ASYNC)
    public void onWorkResult(WorkService.Result result) {
      workIndices.add(result.workIndex);

      if (result.e!=null) {
        WorkTests.this.e=result.e;
      }
      else  {
        String hash=result.hash.hex();

        if (!EXPECTED_HASH_HEX.equals(hash)) {
          WorkTests.this.e=
            new IllegalStateException(String.format("Expected hash of %s, received %s",
              EXPECTED_HASH_HEX, hash));
        }
      }

      latch.countDown();
    }
  }
}
(from JobScheduler/WorkQueue/app/src/androidTest/java/com/commonsware/android/job/work/test/WorkTests.java)

Work Limits

As noted previously, the work queue system with JobScheduler relies on your using the same or an equivalent JobInfo for each piece of work. Otherwise, our JobService needs to stop processing jobs and restart them, perhaps after some delay.