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 864: PowerHungry Demo, As Initially Launched

A Switch (in its Theme.Material styling) 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 onCreate() of the activity:

    jobs=(JobScheduler)getSystemService(JOB_SCHEDULER_SERVICE);

(from JobScheduler/PowerHungry/app/src/main/java/com/commonsware/android/job/MainActivity.java)

While this may look like an ordinary request for a system service, it has one issue: your version of Android Studio may not know anything about it. In that case, this code will fail a Lint check, complaining that the service is not recognized. This is a bug that should be fixed in Android Studio 0.8.14. The workaround is to add @SuppressWarnings("ResourceType") to the method where you are making this getSystemService() call to suppress this Lint check.

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)

onCreate() 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 onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    setContentView(R.layout.main);
    type=(Spinner)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=(Spinner)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=(Switch)findViewById(R.id.download);
    scheduled=(Switch)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:

  @Override
  public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
    toggleWidgets(!isChecked);

    switch(type.getSelectedItemPosition()) {
      case 0:
        manageExact(isChecked);
        break;

      case 1:
        manageInexact(isChecked);
        break;

      case 2:
        manageJobScheduler(isChecked);
        break;
    }
  }

(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);
  }

(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 WakefulBroadcastReceiver that is set up for handling our alarms:

package com.commonsware.android.job;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.SystemClock;
import android.support.v4.content.WakefulBroadcastReceiver;

public class PollReceiver extends WakefulBroadcastReceiver {
  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);
    startWakefulService(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 an IntentService wrapper around the DownloadJob that we used with DemoJobService, logging the fact that it ran and calling completeWakefulIntent() to indicate that the work initiated by the WakefulBroadcastReceiver was done.

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.

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 {
    compile 'com.android.support:support-v13:24.2.0'
    compile 'com.evernote:android-job:1.0.11'
}

(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 {
  public 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())
        .setPersisted(false)
        .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 865: 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 866: 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 867: 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 {
    compile 'org.greenrobot:eventbus:3.0.0'
    compile 'com.android.support:support-v13:25.0.1'
}

android {
    compileSdkVersion 25
    buildToolsVersion "25.0.0"

    defaultConfig {
        minSdkVersion 19
        targetSdkVersion 23
    }

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

        normal {
            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.app.Activity;
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 Activity {
  @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 (getFragmentManager().findFragmentById(android.R.id.content)==null) {
      getFragmentManager().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() {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this);
    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).

The AlarmManager/AlarmClock sample application demonstrates the use of setAlarmClock() as an alternative to setExactAndAllowWhileIdle().

This app is reminiscent of the AntiDoze sample from earlier in this chapter. Once again, we have a fork off of an earlier demo of using greenrobot’s EventBus to handle notifications from periodic work. In this case, rather than using AlarmManager and setRepeating() (as the original demo used) or using ScheduledExecutorService (as AntiDoze used), we use setAlarmClock() on AlarmManager.

PollReceiver now has a substantially different scheduleAlarms() implementation, along with a slightly different onReceive() implementation:

package com.commonsware.android.alarmclock;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.SystemClock;
import com.commonsware.cwac.wakeful.WakefulIntentService;

public class PollReceiver extends BroadcastReceiver {
  private static final int PERIOD=15000; // 15 seconds

  @Override
  public void onReceive(Context ctxt, Intent i) {
    if (i.getAction()==null) {
      WakefulIntentService.sendWakefulWork(ctxt, ScheduledService.class);
    }

    scheduleAlarms(ctxt);
  }

  static void scheduleAlarms(Context ctxt) {
    AlarmManager mgr=
      (AlarmManager)ctxt.getSystemService(Context.ALARM_SERVICE);
    Intent i=new Intent(ctxt, PollReceiver.class);
    PendingIntent pi=PendingIntent.getBroadcast(ctxt, 0, i, 0);
    Intent i2=new Intent(ctxt, EventDemoActivity.class);
    PendingIntent pi2=PendingIntent.getActivity(ctxt, 0, i2, 0);

    AlarmManager.AlarmClockInfo ac=
      new AlarmManager.AlarmClockInfo(System.currentTimeMillis()+PERIOD,
        pi2);

    mgr.setAlarmClock(ac, pi);
  }
}

(from AlarmManager/AlarmClock/app/src/main/java/com/commonsware/android/alarmclock/PollReceiver.java)

scheduleAlarms() creates a PendingIntent identifying the PollReceiver itself, as was done in the original demo. This sample app is using WakefulIntentService, and the rules for wakeup-style alarms is that you should have the PendingIntent be a broadcast one. While it is unclear if setAlarmClock() has the same requirement, it seems reasonably likely.

However, scheduleAlarms() then creates a second PendingIntent, one pointing to the EventDemoActivity. That PendingIntent is supplied to the constructor to AlarmManager.AlarmClockInfo, along with the time we want the alarm to go off, expressed in the RTC-style timebase (i.e., milliseconds since the Unix epoch, System.currentTimeMillis()). We will see in a bit where that PendingIntent gets used.

Then, we call setAlarmClock() on AlarmManager, providing the AlarmClockInfo object and the first PendingIntent, to be invoked at the time indicated in the AlarmClockInfo.

As with the original example, onReceive() is used both for ACTION_BOOT_COMPLETED and for the one AlarmManager PendingIntent. To distinguish between these cases, onReceive() examines the action string of the incoming Intent — if this is not null, it must be the ACTION_BOOT_COMPLETED broadcast, as we did not put an action string in the Intent used to create the PendingIntent in scheduleAlarms(). If the Intent action is null, though, this is our PendingIntent invocation, so we call sendWakefulWork() to have the ScheduledService do something (in this case, log a message to a file and use EventBus to let the UI layer know about the event). However, in either case (Intent action is null or not), we call scheduleAlarms() to set up the next event, as setAlarmClock() is a one-shot alarm, not a recurring alarm.

The net effect is that if you run this app, your code gets control every 15 seconds, updating the fragment (via the event bus) and logging a line to a log file (using an append() method akin to the one from AntiDoze). More importantly, this will continue working despite Doze mode, even without your app being on the whitelist.

The biggest issue with setAlarmClock() is that it is visible to the user:

Notification Shade, Showing Upcoming Alarm
Figure 868: Notification Shade, Showing Upcoming Alarm

By default, executing this PendingIntent will start up the activity in a new task, and so you will need to consider using android:launchMode or android:taskAffinity to redirect the activity back to your original task.

For an app offering calendar-style reminders, none of this is necessarily a bad thing. You would tie the PendingIntent for the AlarmClockInfo object to the activity that shows details of that particular appointment, so the user can review the details, remove the reminder request, etc.

For an app looking to do periodic work, the ever-present icon may aggravate some users, particularly those using alarm clock apps for actual alarm clock work and wondering why an alarm is set.

Also note that the manual rescheduling means that you are likely to have a bit of drift for periodic work. In the case of the sample app, each event will occur at least 15000 milliseconds apart. In reality, it will be slightly more, reflecting the execution time between when the system recognizes that it is time to invoke the alarm and the time when we call setAlarmClock() again. Many apps can just live with the drift. If this is an issue for you, you can try to minimize the drift by doing a more elaborate calculation of the next alarm time, one that cancels out previous drift.

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:

  private static final int ME_MYSELF_AND_I=3493;
  private static final int NOTIFY_ID=2343;

  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) {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this)
        .setAutoCancel(true)
        .setDefaults(Notification.DEFAULT_ALL)
        .setContentTitle("You added a contact!")
        .setSmallIcon(android.R.drawable.stat_notify_more);

    NotificationManager mgr=
      (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

    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: