In this codelab, you'll learn how to convert long-running or periodic background services into jobs that can be dispatched by the framework when it's ready. You'll work through a simple case study that demonstrates how to take advantage of either the open source Firebase JobDispatcher library (available on devices with Google Play Services installed) or the Android framework's JobScheduler (available on Lollipop+ devices).

Why is this important

In order to preserve battery life, newer versions of Android impose more strict limitations on background services. Using a job framework - like Android's JobScheduler or the Firebase JobDispatcher library - is the preferred way of doing work while your app isn't in the foreground.

What you'll learn

What you'll need

How will you use this tutorial?

Read it through only Read it and complete the exercises

How would rate your experience with building Android apps?

Novice Intermediate Proficient

Have you ever used the any of the following APIs in your apps?

GCM Network Manager Android JobScheduler Android AlarmManager

You can either download all the sample code to your computer...

Download Zip

...or clone the GitHub repository from the command line.

$ git clone https://github.com/googlecodelabs/android-migrate-to-jobs.git

First, let's see what the finished sample app looks like. With the code downloaded, the following instructions describe how to open the completed sample app in Android Studio.

  1. Import the cloned repository in Android Studio. (Quickstart > Import Project... > android-migrate-to-jobs).
  2. Click the Gradle sync button.
  3. Enable USB debugging on your Android device.
  4. Plug in your Android device.
  5. Click the Run... button in the top menubar. Select one of the three variants ("original_app", "jobdispatcher_complete", "jobscheduler_complete"). You should see the test app home screen appear on your Android device after a few seconds.

  1. Tap one of the books on the device, which will toggle its state. "Downloaded" books will be removed, removed books will begin "downloading", and books that are currently "downloading" will stop. This is faking an HTTP connection. No downloads are actually happening.
  2. Try enabling Airplane mode while a book "download" is in-progress. Verify that the progress stops. Disabling Airplane should resume the "download" after a couple of seconds.

Frequently Asked Questions

Before we make any dramatic changes, it's worth understanding how the sample app is organized. If this inspection is too detailed for your available time, you can skip ahead to "Creating a new module to work in" and get back to coding. Each module-specific class is generally prefixed with the module name. For the "original_app" module the prefix is original. Let's start by opening the original_app's main Activity.

OriginalCatalogListActivity.java

package com.google.codelabs.migratingtojobs.original_app;

import com.google.codelabs.migratingtojobs.shared.CatalogListActivity;

public class OriginalCatalogListActivity extends CatalogListActivity {
    @Override
    protected void inject() {
        OriginalGlobalState.get(getApplication()).inject(this);
    }
}

There's not much module-specific code here, just a reference to the OriginalGlobalState class. That's where most of the interesting initialization code is happening, so let's look there:

OriginalGlobalState.java

package com.google.codelabs.migratingtojobs.original_app;

import android.content.Context;

import com.google.codelabs.migratingtojobs.shared.AppModule;
import com.google.codelabs.migratingtojobs.shared.ErrorLoggingDownloadListener;
import com.google.codelabs.migratingtojobs.shared.EventBus;
import com.google.codelabs.migratingtojobs.shared.SharedInitializer;

import javax.inject.Inject;

public class OriginalGlobalState {
    private static OriginalGlobalState sInstance;

    public static OriginalComponent get(Context app) {
        if (sInstance == null) {
            synchronized (SharedInitializer.class) {
                if (sInstance == null) {
                    sInstance = new OriginalGlobalState(app);

                    sInstance.init();
                }
            }
        }

        return sInstance.get();
    }

    private final OriginalComponent component;

    @Inject
    SharedInitializer sharedInitializer;
    @Inject
    EventBus bus;

    public OriginalGlobalState(Context app) {
        component = DaggerOriginalComponent.builder()
                .appModule(new AppModule(app))
                .build();

        component.inject(this);
    }

    private void init() {
        sharedInitializer.init();
        bus.register(new ErrorLoggingDownloadCallback());
    }

    public OriginalComponent get() {
        return component;
    }
}

There's a lot more going on here, so let's step through it piece by piece. First, there's a basic singleton implementation:

    private static OriginalGlobalState sInstance;

    public static OriginalComponent get(Context app) {
        if (sInstance == null) {
            synchronized (SharedInitializer.class) {
                if (sInstance == null) {
                    sInstance = new OriginalGlobalState(app);

                    sInstance.init();
                }
            }
        }

        return sInstance.get();
    }

The get method takes a Context and - if this is the first time it's been called - uses it to initialize a static class member. Most of the interesting parts are in the constructor and the inject method:

    private final OriginalComponent component;

    @Inject
    SharedInitializer sharedInitializer;
    @Inject
    EventBus bus;

    public OriginalGlobalState(Context app) {
        component = DaggerOriginalComponent.builder()
                .appModule(new AppModule(app))
                .build();

        component.inject(this);
    }

    private void init() {
        sharedInitializer.init();

        bus.register(new ErrorLoggingDownloadListener());

    }

    public OriginalComponent get() {
        return component;
    }

The constructor creates a new "DaggerOriginalComponent" and then calls its inject method. The DaggerOriginalComponent class is generated by Dagger2, a fast, open-source dependency injection solution. The Dagger-provided inject method populates all the variables marked with "@Inject". After that's complete, the static get method above calls init, which in turn calls sharedInitializer.init() and registers a custom "ErrorLoggingDownloadListener."

So how does Dagger know how to build our object graph? It uses an interface to determine which methods to generate. In the "original_app" module, that interface is OriginalComponent:

OriginalComponent.java

package com.google.codelabs.migratingtojobs.original_app;

import com.google.codelabs.migratingtojobs.shared.AppModule;
import com.google.codelabs.migratingtojobs.shared.RootComponent;

import javax.inject.Singleton;

import dagger.Component;

@Singleton
@Component(modules = {AppModule.class})
public interface OriginalComponent extends RootComponent {
    void inject(OriginalGlobalState originalGlobalState);
    void inject(OriginalCatalogListActivity activity);
    void inject(ConnectivityChangeReceiver receiver);
}

There's two important pieces of code to take note of:

Now that we know roughly how our object graph is created, let's examine how events flow through the application, starting with the EventBus we saw above:

EventBus.java

package com.google.codelabs.migratingtojobs.shared;

import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.NonNull;
import android.util.Log;

import java.util.LinkedList;
import java.util.List;

/** EventBus provides an app-wide handler loop that distributes new events to listeners. */
public class EventBus {
    public interface EventListener {
        void onInit(CatalogItemStore itemStore);

        void onActivityCreated();

        void onActivityDestroyed();

        void onItemDownloadFailed(CatalogItem item);

        void onItemDownloadStarted(CatalogItem item);

        void onItemDownloadFinished(CatalogItem item);

        void onItemDownloadCancelled(CatalogItem item);

        void onItemDownloadInterrupted(CatalogItem item);

        void onItemDownloadIncrementProgress(CatalogItem item);

        void onItemDeleteLocalCopy(CatalogItem item);

        void onAllDownloadsFinished();

        void onRetryDownloads(CatalogItemStore itemStore);

        // Handle a custom message.
        void handle(Message msg);
    }

    // ...
}

This defines an interface for a listener that's aware of a predefined set of app-wide events. It also has a handle(Message) method to allow for custom events. Events are posted to the EventBus, which is responsible for multiplexing them to all interested listeners. Internally the EventBus uses a Handler that relies on the Looper from a dedicated worker thread, so posting events doesn't lock up the main (UI) thread. Events eventually make their way to the data layer (the CatalogItem class). Changes in the CatalogItem objects are automatically propagated to the UI via Android's data binding framework.

The exact implementation of the EventBus isn't particularly interesting, but we should take note of the register and unregister methods:

    public void register(EventListener eventListener) {
        send(REGISTER, eventListener);
    }

    public void unregister(EventListener eventListener) {
        send(UNREGISTER, eventListener);
    }

Now that we know roughly how events are propagated through the system, we can take a look at the download listener we registered above:

ErrorLoggingDownloadListener.java

package com.google.codelabs.migratingtojobs.shared;

import android.util.Log;

import java.util.Locale;

public class ErrorLoggingDownloadListener extends BaseEventListener {
    private static final String TAG = Downloader.TAG;

    @Override
    public void onItemDownloadFailed(CatalogItem item) {
        Log.i(TAG, String.format(Locale.US,
                "Encountered error downloading %s", item.getBook().getTitle()));
    }
}

All this does is log an error when the item download fails. We'll use this as a base when we start to implement more sophisticated error handling later.

On the final stop of our original_app module tour, let's examine the CONNECTIVITY_ACTION BroadcastReceiver that we're using to implement download retries:

ConnectivityChangeReceiver.java

package com.google.codelabs.migratingtojobs.original_app;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.ConnectivityManager;

import com.google.codelabs.migratingtojobs.shared.CatalogItemStore;
import com.google.codelabs.migratingtojobs.shared.EventBus;

import javax.inject.Inject;

public class ConnectivityChangeReceiver extends BroadcastReceiver {
    private boolean isInitialized;

    @Inject
    EventBus bus;

    @Inject
    CatalogItemStore itemStore;

    @Override
    public void onReceive(Context context, Intent intent) {
        if (intent == null || !ConnectivityManager.CONNECTIVITY_ACTION.equals(intent.getAction())) {
            return;
        }

        injectOnce(context);

        bus.postRetryDownloads(itemStore);
    }

    private void injectOnce(Context context) {
        if (!isInitialized) {
            synchronized (this) {
                if (!isInitialized) {
                    OriginalGlobalState.get(context.getApplicationContext()).inject(this);
                    isInitialized = true;
                }
            }
        }
    }
}

We're retrieving our OriginalGlobalState singleton and using that to inject our instance fields, just like in the CatalogListActivity above. Then we post a new event to the EventBus (postRetryDownloads), along with a reference to our application data store (CatalogItemStore). This is all the code we need to trigger retrying our downloads.

The sample app uses a broadcast receiver that's listening for the android.net.conn.CONNECTIVITY_CHANGE broadcast. While it works for our purposes, this approach has a couple of major flaws:

Our goal is to switch our sample app from relying on the broadcast receiver to using a background job instead. In order to do that, we'll need a new module to work in:

  1. Create a new module in Android Studio (File -> New -> New Module... -> Phone & Tablet Module). We'll use the module name "jobs", and the package name com.google.codelabs.migratingtojobs.jobs. The minimum SDK should be at least 21 (Lollipop) in order to use the framework JobScheduler API. We don't need to add any new activities (Add No Activity).
  2. Add a dependency on the "shared" module from your new module. Right click on the module name in the sidebar and click "Open Module Settings". Navigate to the "Dependencies" tab and click the + near the bottom. Select "Module dependency" and choose the ":shared" option.
  3. Open the new module's build.gradle file. You'll need to add a new dataBinding section to the android configuration in order to compile the rest of the project:
android {
    // other settings generated by Android Studio

    dataBinding {
        enabled = true
    }
}
  1. We'll be using Dagger2 to generate our dependency injection scaffolding, so we'll also need to add some new dependencies to the same build.gradle file:
dependencies {
    compile 'com.android.support:appcompat-v7:23.3.0'
    compile 'com.android.support:design:23.3.0'

    // https://github.com/google/dagger/issues/356
    apt 'com.google.guava:guava:19.0'
    compile 'com.google.dagger:dagger:2.4'
    apt 'com.google.dagger:dagger-compiler:2.4'
    provided 'javax.annotation:jsr250-api:1.0'

    compile project(':shared')
}
  1. Finally we'll need to apply the android-apt plugin to let Dagger hook into the regular Android build process. At the top of the build.gradle file, after applying the "com.android.application" plugin you'll want to apply the "com.neenbedankt.android-apt" plugin. The first two lines of your file should look like this:
apply plugin: 'com.android.application'
apply plugin: 'com.neenbedankt.android-apt'

Your final build file will closely resemble this:

build.gradle

apply plugin: 'com.android.application'
apply plugin: 'com.neenbedankt.android-apt'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    defaultConfig {
        applicationId "com.google.codelabs.migratingtojobs.MY_MODULE_NAME"
        minSdkVersion 21
        targetSdkVersion 23
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    dataBinding {
        enabled = true
    }
}

dependencies {
    compile 'com.android.support:appcompat-v7:23.3.0'
    compile 'com.android.support:design:23.3.0'

    // https://github.com/google/dagger/issues/356
    apt 'com.google.guava:guava:19.0'
    compile 'com.google.dagger:dagger:2.4'
    apt 'com.google.dagger:dagger-compiler:2.4'
    provided 'javax.annotation:jsr250-api:1.0'

    compile project(':shared')
}

Frequently Asked Questions

Now we have a new module, the first step is to configure a Dagger2 Component. Create a new class - for lack of a more inspiring name we'll call it JobsComponent. For now an empty class will work fine:

JobsComponent.java

package com.google.codelabs.migratingtojobs.jobs;

import com.google.codelabs.migratingtojobs.shared.AppModule;

import dagger.Component;

import javax.inject.Singleton;

@Singleton
@Component(modules = {AppModule.class})
public interface JobsComponent extends RootComponent {}

Next, let's create a central place to store all our global initialization logic. Since this captures global state we'll call it JobsGlobalState:

JobsGlobalState.java

package com.google.codelabs.migratingtojobs.jobs;

import android.app.Application;

import com.google.codelabs.migratingtojobs.shared.AppModule;
import com.google.codelabs.migratingtojobs.shared.EventBus;
import com.google.codelabs.migratingtojobs.shared.SharedInitializer;

import javax.inject.Inject;

public class JobsGlobalState {
    private static JobsGlobalState sInstance;

    public static JobsComponent get(Application app) {
        if (sInstance == null) {
            synchronized (JobsGlobalState.class) {
                if (sInstance == null) {
                    sInstance = new JobsGlobalState(app);

                    sInstance.init();
                }
            }
        }

        return sInstance.get();
    }

    private final JobsComponent component;

    @Inject
    SharedInitializer sharedInitializer;

    @Inject
    EventBus bus;

    private JobsGlobalState(Application app) {
        component = DaggerJobsComponent.builder()
                .appModule(new AppModule(app))
                .build();

        component.inject(this);
    }

    private void init() {
        sharedInitializer.init();
    }

    public JobsComponent get() {
        return component;
    }
}

There's no custom initialization here yet, just a singleton implementation that calls the SharedInitializer class. This also won't compile without adding a new inject method to the JobsComponent interface:

JobsComponent.java

package com.google.codelabs.migratingtojobs.jobs;

import com.google.codelabs.migratingtojobs.shared.AppModule;

import dagger.Component;

import javax.inject.Singleton;

@Singleton
@Component(modules = {AppModule.class})
public interface JobsComponent extends RootComponent {
    public void inject(JobsGlobalState globalState);
}

All we've done here is add an inject method that's been specialized for our new JobsGlobalState class. That's enough for Dagger to generate the appropriate JobsComponent implementation.

Now we've got some infrastructure in place, let's add our main Activity. Create a new class, "JobsCatalogListActivity":

JobsCatalogListActivity.java

package com.google.codelabs.migratingtojobs.jobs;

import com.google.codelabs.migratingtojobs.shared.CatalogListActivity;

public class JobsCatalogListActivity extends CatalogListActivity {
  @Override
  protected void inject() {
      JobsGlobalState.get(getApplication()).inject(this);
  }
}

As before, this won't compile until we add a new inject method to our JobsComponent:

JobsComponent.java

package com.google.codelabs.migratingtojobs.jobs;

import com.google.codelabs.migratingtojobs.shared.AppModule;

import dagger.Component;

import javax.inject.Singleton;

@Singleton
@Component(modules = {AppModule.class})
public interface JobsComponent extends RootComponent {
    public void inject(JobsGlobalState globalState);
    public void inject(JobsCatalogListActivity activity);
}

To finish adding the activity we'll have to register it in the manifest. Open the module's AndroidManifest.xml and register a launcher activity inside the <application> tag:

AndroidManifest.xml

        <activity
            android:name=".JobsCatalogListActivity"
            android:label="[Jobs] Book Catalog">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

A JobService is an Android Service that overrides certain methods with app-specific logic. We can use either the Firebase JobDispatcher's JobService or the Android framework's JobService; both offer the same service API. The Android framework's JobScheduler is available on devices that are running Android Lollipop and newer. The Firebase JobDispatcher will work on devices that have Google Play services installed. If you're planning on using the framework's JobService you can skip this section.

In order to use the Firebase JobDispatcher (FJD), we'll first need to add a dependency on the library. Open your module's build.gradle file and add the following line to your dependencies block:

    compile project(':firebase-jobdispatcher')

Next, we need to let Dagger know how to build a dispatcher. Create a new class called JobsModule and add the following:

JobsModule.java

package com.google.codelabs.migratingtojobs.jobs;

import android.content.Context;

import com.firebase.jobdispatcher.FirebaseJobDispatcher;
import com.firebase.jobdispatcher.GooglePlayDriver;

import javax.inject.Singleton;

import dagger.Module;
import dagger.Provides;

@Module
@Singleton
public class JobsModule {
    @Provides
    @Singleton
    FirebaseJobDispatcher provideFirebaseJobDispatcher(Context context) {
        return new FirebaseJobDispatcher(new GooglePlayDriver(context));
    }
}

This just instructs Dagger to use a single instance of the above-created dispatcher. The GooglePlayDriver instructs the dispatcher to use the scheduling engine in Google Play services.

Now all that's left is to adjust the JobsComponent interface to use our new JobsModule.

JobsComponent.java

package com.google.codelabs.migratingtojobs.jobs;

import com.google.codelabs.migratingtojobs.shared.AppModule;

import dagger.Component;

import javax.inject.Singleton;

@Singleton
@Component(modules = {AppModule.class, JobsModule.class})
public interface JobsComponent extends RootComponent {
    public void inject(JobsGlobalState globalState);
    public void inject(JobsCatalogListActivity activity);
}

Perfect!

We'll need a JobService subclass that can handle retrying the downloads when it's executed. Add a new "DownloaderJobService" class to the module. If you're using the framework JobScheduler, make sure it extends "android.app.job.JobService". If you're using the Firebase JobDispatcher, make sure it extends "com.firebase.jobdispatcher.JobService".

Here's what an empty Firebase JobDispatcher version might look like:

package com.google.codelabs.migratingtojobs.jobs;

import com.firebase.jobdispatcher.JobService;
import com.firebase.jobdispatcher.JobSpec;

public class DownloaderJobService extends JobService {
    @Override
    public boolean onStartJob(JobSpec jobSpec) {
        return true; // true if we're not done yet
    }

    @Override
    public boolean onStopJob(JobSpec jobSpec) {
        // true if we'd like to be rescheduled
        return true;
    }

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

    @Override
    public void onDestroy() {
        super.onDestroy();
    }
}

Before we begin writing any real code, let's first examine the constraints we have to work with:

Now that we understand some of the constraints, we can begin formulating an approach:

Instead of co-opting a pre-existing app event, we'll use a static helper and the send(Message) method to simplify adding custom events:

JobEvents.java

package com.google.codelabs.migratingtojobs.jobs;

import com.google.codelabs.migratingtojobs.shared.EventBus;

public class JobEvents {
    public static final int DOWNLOAD_JOB_FINISHED = EventBus.FIRST_UNUSED;
    public static final int DOWNLOAD_JOB_FAILED = EventBus.FIRST_UNUSED + 1;

    public static void postDownloadJobFinished(EventBus bus) {
        bus.send(DOWNLOAD_JOB_FINISHED);
    }

    public static void postDownloadJobFailed(EventBus bus) {
        bus.send(DOWNLOAD_JOB_FAILED);
    }
}

Here's a simple JobService implementation that illustrates the above points:

DownloaderJobService.java

package com.google.codelabs.migratingtojobs.jobs;

import com.firebase.jobdispatcher.JobService;
import com.firebase.jobdispatcher.JobSpec;

import com.google.codelabs.migratingtojobs.shared.BaseEventListener;
import com.google.codelabs.migratingtojobs.shared.CatalogItem;
import com.google.codelabs.migratingtojobs.shared.CatalogItemStore;
import com.google.codelabs.migratingtojobs.shared.EventBus;

import java.util.LinkedList;
import java.util.List;

import javax.inject.Inject;

public class DownloaderJobService extends JobService {
    /**
     * List of all listeners we register, so we can make sure they get unregistered when this
     * service goes away.
     */
    final List<EventBus.EventListener> eventListeners = new LinkedList<>();

    @Inject
    EventBus bus;

    @Inject
    CatalogItemStore itemStore;

    @Override
    public boolean onStartJob(JobParameters job) {
        EventListener listener = new EventListener(this, job, bus);
        synchronized (eventListeners) {
            eventListeners.add(listener);
            bus.register(listener);
        }

        // TRIGGER WORK
        bus.postRetryDownloads(itemStore);

        return true; // true because there's more work being done on a separate thread
    }

    @Override
    public boolean onStopJob(JobParameters job) {
        // If this is being called it means we haven't explicitly finished our work yet.
        // Return true so we get rescheduled.
        return true;
    }

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

        // Initialize everything if it's not already, plus inject dependencies.
        JobsGlobalState.get(getApplication()).inject(this);
    }

    @Override
    public void onDestroy() {
        synchronized (eventListeners) {
            for (EventBus.EventListener listener : eventListeners) {
                // unregistering prevents leaks.
                bus.unregister(listener);
            }
        }

        super.onDestroy();
    }

    private final static class EventListener extends BaseEventListener {
        private final JobService service;
        private final JobParameters job;
        private final EventBus bus;

        public EventListener(JobService service, JobParameters job, EventBus bus) {
            this.service = service;
            this.job = job;
            this.bus = bus;
        }

        @Override
        public void onItemDownloadFailed(CatalogItem item) {
            service.jobFinished(job, true);
            JobEvents.postDownloadJobFailed(bus);
        }

        @Override
        public void onAllDownloadsFinished() {
            service.jobFinished(job, false);
            JobEvents.postDownloadJobFinished(bus);
        }
    }
}

As before, we need to add a new inject method to the JobsComponent before this will compile:

JobsComponent.java

package com.google.codelabs.migratingtojobs.jobs;

import com.google.codelabs.migratingtojobs.shared.AppModule;

import dagger.Component;

import javax.inject.Singleton;

@Singleton
@Component(modules = {AppModule.class, JobsModule.class})
public interface JobsComponent extends RootComponent {
    public void inject(JobsGlobalState globalState);
    public void inject(JobsCatalogListActivity activity);
    public void inject(DownloaderJobService jobService);
}

Frequently Asked Questions

Now we have a JobService, our next step is to schedule it when a download fails. Just like in the original app, we'll use an event listener for this. Create a new class - we'll call it "JobSchedulingErrorListener" - that extends from com.google.codelabs.migratingtojobs.shared.BaseEventListener. We'll need to override the onItemDownloadFailed(CatalogItem) and handle(Message) methods. When we receive a new onItemDownloadFailed call, we check to see if we've already scheduled a job; if we haven't, we submit a Runnable that does so on a worker thread. When we receive a message via the handle method, we check whether it has the DOWNLOAD_JOB_FINISHED constant we defined in JobEvents above. If it's a match, we know the job was executed successfully and thus we can freely reschedule the next time a download fails. Here's a Firebase JobDispatcher version:

JobDispatchingErrorListener.java

package com.google.codelabs.migratingtojobs.jobs;

import android.os.Message;
import android.support.annotation.NonNull;
import android.util.Log;

import com.firebase.jobdispatcher.Constraint;
import com.firebase.jobdispatcher.FirebaseJobDispatcher;
import com.firebase.jobdispatcher.Job;
import com.google.codelabs.migratingtojobs.shared.BaseEventListener;
import com.google.codelabs.migratingtojobs.shared.CatalogItem;

import java.util.concurrent.ExecutorService;

import javax.inject.Inject;
import javax.inject.Named;

public class JobDispatchingErrorListener extends BaseEventListener {
    @NonNull
    private final ExecutorService executorService;

    private final ScheduleRunnable scheduleRunnable;

    private boolean jobScheduled = false;

    @Inject public JobDispatchingErrorListener(@NonNull FirebaseJobDispatcher dispatcher,
                                               @NonNull @Named("worker") ExecutorService executorService) {
        this.executorService = executorService;
        this.scheduleRunnable = new ScheduleRunnable(dispatcher);
    }

    @Override
    public void onItemDownloadFailed(CatalogItem item) {
        // most checks shouldn't have to wait for the synch lock
        if (!jobScheduled) {
            synchronized (this) {
                if (!jobScheduled) {
                    executorService.submit(scheduleRunnable);
                    jobScheduled = true;
                }
            }
        }
    }

    @Override
    public void handle(Message msg) {
        if (msg.what == JobEvents.DOWNLOAD_JOB_FINISHED) {
            synchronized (this) {
                jobScheduled = false;
            }
        }
    }

    private static class ScheduleRunnable implements Runnable {
        private static final String TAG = "FJD D/L Handler";

        private final FirebaseJobDispatcher dispatcher;
        private final Job jobToSchedule;

        public ScheduleRunnable(FirebaseJobDispatcher dispatcher) {
            this.dispatcher = dispatcher;
            this.jobToSchedule = dispatcher.newJobBuilder()
                    .setTag("downloader_job")
                    .setService(DownloaderJobService.class)
                    .setConstraints(Constraint.ON_ANY_NETWORK)
                    .build();
        }

        @Override
        public void run() {
            Log.v(TAG, "Scheduling download job");
            dispatcher.mustSchedule(this.jobToSchedule);
        }
    }
}

And here's an example using the framework JobScheduler:

JobSchedulingErrorListener.java

package com.google.codelabs.migratingtojobs.jobs;

import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.content.ComponentName;
import android.content.Context;
import android.os.Message;
import android.support.annotation.NonNull;
import android.util.Log;

import com.google.codelabs.migratingtojobs.shared.BaseEventListener;
import com.google.codelabs.migratingtojobs.shared.CatalogItem;

import java.util.concurrent.ExecutorService;

import javax.inject.Inject;
import javax.inject.Named;

public class JobSchedulingErrorListener extends BaseEventListener {
    public final static int DOWNLOAD_JOB_ID = 1;

    private static final String TAG = "JS D/L Handler";

    private final ExecutorService executorService;
    /**
     * All calls to the JobScheduler go through IPC, so we do it all on a worker thread in this
     * Runnable.
     */
    private final Runnable scheduleRunnable;
    /**
     * Whether we've already scheduled a job. We should avoid making too many expensive IPC calls,
     * so check here (synchronize on `this`) first.
     */
    private boolean jobScheduled = false;

    @Inject
    public JobSchedulingErrorListener(@NonNull Context appContext,
                                      @Named("worker") ExecutorService executorService) {
        this.scheduleRunnable = new ScheduleRunnable(appContext);
        this.executorService = executorService;
    }

    @Override
    public void onItemDownloadFailed(CatalogItem item) {
        // most checks shouldn't have to wait for the synch lock
        if (!jobScheduled) {
            synchronized (this) {
                if (!jobScheduled) {
                    executorService.submit(scheduleRunnable);
                    jobScheduled = true;
                }
            }
        }
    }

    @Override
    public void handle(Message msg) {
        if (msg.what == JobEvents.DOWNLOAD_JOB_FINISHED) {
            synchronized (this) {
                jobScheduled = false;
            }
        }
    }

    private static class ScheduleRunnable implements Runnable {
        private final Context appContext;
        private final JobInfo jobToSchedule;

        public ScheduleRunnable(Context appContext) {
            this.appContext = appContext;
            this.jobToSchedule = new JobInfo.Builder(
                    DOWNLOAD_JOB_ID, new ComponentName(appContext, DownloaderJobService.class))
                    // Normally you'd want this be NETWORK_TYPE_UNMETERED, but the
                    // ConnectivityManager hack we're using in Downloader only checks for "a"
                    // connection, so let's emulate that here.
                    .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
                    .build();
        }

        @Override
        public void run() {
            JobScheduler js = (JobScheduler) appContext
                    .getSystemService(Context.JOB_SCHEDULER_SERVICE);

            if (js == null) {
                Log.e(TAG, "unable to retrieve JobScheduler. What's going on?");
                return;
            }

            Log.i(TAG, "scheduling new job");
            if (js.schedule(jobToSchedule) == JobScheduler.RESULT_FAILURE) {
                Log.e(TAG, "encountered unknown error scheduling job");
            }
        }
    }
}

As you can see, the two are remarkably similar. Also notice the @Inject annotation the constructor for each version. This tells Dagger that the class is eligible for dependency injection, which means they can be required via fields marked with @Inject or @Inject constructor parameters on another class.

Frequently Asked Questions

Almost done! We just need to finish wiring everything together. Open the JobsGlobalState file that we were working in earlier. Recall that the previous version (with the singleton parts removed) looked like this:

JobsGlobalState.java

package com.google.codelabs.migratingtojobs.jobs;

import android.app.Application;

import com.google.codelabs.migratingtojobs.shared.AppModule;
import com.google.codelabs.migratingtojobs.shared.EventBus;
import com.google.codelabs.migratingtojobs.shared.SharedInitializer;

import javax.inject.Inject;

public class JobsGlobalState {
    private final JobsComponent component;

    @Inject
    SharedInitializer sharedInitializer;

    @Inject
    EventBus bus;

    private JobsGlobalState(Application app) {
        component = DaggerJobsComponent.builder()
                .appModule(new AppModule(app))
                .build();

        component.inject(this);
    }

    private void init() {
        sharedInitializer.init();
    }
}

As you might be able to guess, the next step is just to add a new @Inject'd field for our newly created JobSchedulingErrorListener and to make sure it gets registered in the init method:

JobsGlobalState.java

package com.google.codelabs.migratingtojobs.jobs;

import android.app.Application;

import com.google.codelabs.migratingtojobs.shared.AppModule;
import com.google.codelabs.migratingtojobs.shared.EventBus;
import com.google.codelabs.migratingtojobs.shared.SharedInitializer;

import javax.inject.Inject;

public class JobsGlobalState {
    private final JobsComponent component;

    @Inject
    SharedInitializer sharedInitializer;

    @Inject
    EventBus bus;

    @Inject
    JobSchedulingErrorListener jobSchedulingErrorListener;

    private JobsGlobalState(Application app) {
        component = DaggerJobsComponent.builder()
                .appModule(new AppModule(app))
                .build();

        component.inject(this);
    }

    private void init() {
        sharedInitializer.init();

        bus.register(jobSchedulingErrorListener);
    }
}

Simple.

Finally, we need to update the manifest to expose our new JobService. If you're using the Firebase JobDispatcher, all you need is a <service> tag with the right <intent-filter>. Make sure android:exported is false!

AndroidManifest.xml

<service android:name=".DownloaderJobService" android:exported="false">
  <intent-filter>
    <action android:name="com.firebase.jobdispatcher.ACTION_EXECUTE"/>
  </intent-filter>
</service>

If you're using the framework JobService, you'll have to guard it with a framework permission, but you can drop the <intent-filter>:

AndroidManifest.xml

<service android:name=".DownloaderJobService"
  android:permission="android.permission.BIND_JOB_SERVICE"
  android:exported="true" />

Just like before, click the Run... button, but this time select the new module you've been working on (jobs).

Once the app is installed on the device, try starting downloads for each book and enabling Airplane mode before they complete. Close the app (right swipe in the app switcher) and re-enable Airplane mode. Wait a minute and then open the app, all the requested downloads should have finished in the background. Magic! Look at the logcat for the app and trace the flow of events as they affect the individual parts.

We've covered how to migrate from background services and custom error handling logic to the Android framework's JobScheduler. These changes will make your apps more battery efficient and responsive, and your users will appreciate the improved robustness in the face of intermittent network connectivity that the JobScheduler can bring.

What we've covered

Next Steps

Learn More