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.
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.
AlarmManager
does its job, and frequently does it well. However, it is far
from perfect:
ACTION_BOOT_COMPLETED
BroadcastReceiver
to re-establish our alarms_WAKEUP
alarm, forcing us to use tools like WakefulBroadcastReceiver
to make sure
that we can get our work done without the device falling back asleepAnd so on. AlarmManager
is nice, but it would be better to have another solution.
JobScheduler
was designed to handle those four problems outlined above:
RECEIVE_BOOT_COMPLETED
permission for this to work. Also note that you do not have to have jobs
be persisted — this is an opt-in capability of JobScheduler
.WakeLock
, so we do not have to
worry about it ourselves.JobScheduler
implements a configurable back-off
policy, so we can slow down our attempts to get control when those attempts
are regularly failing.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:
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.
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);
}
}
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:
int
that will serve as the job ID, which needs to be unique to our app
but does not have to be unique for the whole deviceComponentName
identifying the JobService
that will actually implement
the work of the job itselfThe 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);
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.
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);
}
}
}
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);
}
}
}
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.
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" />
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.
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>
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);
}
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);
}
}
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()]);
}
}
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);
}
}
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);
}
}
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);
}
}
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.
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:
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.
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.
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.
There are a few other options for scheduling jobs that may be of use to you in select circumstances:
JobInfo.Builder
has setOverrideDeadline()
, which indicates a maximum delay
for this job before it will be executed even if other criteria (e.g., idleness)
have not been met. Note that this is only available on one-shot jobs, not periodic jobs.JobParameters
passed to onStartJob()
has an isOverrideDeadlineExpired()
method. This will return true
if the job was executed early due to a
setOverrideDeadline()
value being met. This will indicate to you that your
requirements may not be met (e.g., Internet access) and you will need to double-check
those things yourself.JobInfo.Builder
has setMinimumLatency()
which sets a minimum delay time; the
job will not be considered until at least this amount of time has elapsed.
Note that this is only available on one-shot jobs, not periodic jobs.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.
As of Android 7.0,
JobScheduler
does not support jobs running more frequently
than once every 15 minutes.
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.
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.
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'
}
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:
Result.SUCCESS
, meaning that life is goodResult.RESCHEDULE
, meaning that you did not do the work, and it should
be rescheduled to be tried again shortlyResult.FAILURE
, meaning that you did not do the work, but there
is no reason to reschedule the jobThe 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);
}
}
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.
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);
}
}
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());
}
}
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">
Given all of that prep work, we can now actually schedule jobs to be
executed. The flow is very similar to that of JobScheduler
:
JobRequest.Builder
with the details
of the job, notably the String
to identify which Job
subclass to
use (by way of the JobCreator
)build()
the JobRequest
using that Builder
, and call
schedule()
on it to schedule the jobint
returned by schedule()
and use that to
cancel()
the job later on, if needed 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);
}
}
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.
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.
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.
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”:
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+.
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.).
AlarmManager
now has two additional methods:
setAndAllowWhileIdle()
setExactAndAllowWhileIdle()
These work better in Doze mode and app standby mode, allowing you to get control briefly even if otherwise you would not. However:
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.
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:
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”):
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:
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:
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
permission via
a <uses-permission>
element in the manifestpackage:
Uri
pointing to your appUri
in an ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
Intent
startActivity()
with that Intent
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"
}
}
}
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:
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 whitelistEventLogFragment
, add itEventLogFragment
, also start up the
ScheduledService
, as probably it is not already runningpackage 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));
}
}
}
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>
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();
In onCreate()
, we:
foregroundify()
method to make our service be a
foreground service with a suitable Notification
, if our IS_FOREGROUND
value is true
based upon on our product flavorFile
for use with logging (named log
), including creating
the directory for it if neededscheduleAtFixedRate()
on the ScheduledExecutorService
to get control every 15 seconds @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);
}
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);
}
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);
}
}
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());
}
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();
}
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).
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).
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.
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);
}
}
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());
}
Here, we:
ComponentName
identifying our JobService
TriggerContentUri
, asking for ContactsContract.Contacts.CONTENT_URI
(imported via import static
), and asking to be notified about changes
in any “descendants” (i.e., already-existing contacts)JobInfo.Builder
JobScheduler
via getSystemService()
JobInfo
and schedule()
it with the JobScheduler
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);
}
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:
setTriggerContentUpdateDelay()
indicates how long after the
last content change before the job will be invoked. For example,
suppose that through some sort of sync operation, a provider that you
are monitoring is updated 10 times within a second, then is quiet.
By default, your job would be invoked 10 times. But, if you
pass something like 3000
to setTriggerContentUpdateDelay()
,
your job would be invoked once, 3000 milliseconds after the last
of that burst of updates.setTriggerContentMaxDelay()
puts an upper bound for how long you
are willing to wait before the job is invoked. If the provider is very
busy, and your setTriggerContentUpdateDelay()
counter keeps getting
reset due to updates, it may be quite some time after the burst
began before you finally have your job run. setTriggerContentMaxDelay()
sets a limit for how long we will wait; if this time elapses, your
job will be run even if updates are ongoing.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.
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”:
int
identifying which piece of work this is 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));
}
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);
}
enqueueWork()
takes three parameters:
Context
JobInfo
returned from a previous enqueueWork()
call, or null
if we
do not have oneJobWorkItem
to enqueue, in this case in the form of a List
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.
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);
}
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);
}
});
}
}
}
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);
For each JobWorkItem
, the ThreadPoolExecutor
does three things on a background
thread:
download()
method to download the file identified by the supplied
URLcompleteWork()
on the JobParameters
, to indicate that this work
item is complete and can be removed from the queuescheduleWork()
again, in case more jobs arrived while we were busy
downloading the fileThe 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));
}
}
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;
}
}
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);
}
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();
}
}
}
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.