Chained Work

Where WorkManager shines is in its support for chained work. Chained work is where you set up work requests that in turn depend upon other work requests. Later work requests in the chain are only performed if the previous ones succeeded. And, work requests can supply data to the next request in the chain, akin to command-line pipelines or basic workflow systems.

Why?

On the one hand, chained work may not seem necessary. In principle, what you do as a series of work requests could be done in one large work request.

The big benefit of splitting the work into separate requests comes with the application of constraints. For example, the sample app that we will examine demonstrates chained work by downloading a ZIP file, then unZIPping it. Downloading a ZIP file requires an Internet connection, but unZIPping it does not. By providing separate constraints for each work request, you can require a network connection for the download, yet not require it for the unZIP task, thereby allowing that work to proceed even if Internet connectivity is lost.

Also, smaller Worker classes can be made more reusable. One can imagine a library of common Worker classes. Rather than having to write your own CompositeWorker that used several Worker classes, you can simply set up a chain using existing APIs.

Chained work also helps to address the delivery of status updates as a larger task is being processed. Each WorkRequest in the chain has its own WorkStatus that can be monitored via LiveData. This way, you can at least get coarse-grained information about how the chain overall is proceeding.

How Do We Chain Work?

To enqueue a WorkRequest, we used enqueue() on the WorkManager instance. In truth, that is a convenience method. This:

WorkManager.getInstance(getApplicationContext())
  .enqueue(request);

is really this:

WorkManager.getInstance(getApplicationContext())
  .beginWith(request)
  .enqueue();

beginWith() returns a WorkContinuation. This is an object that knows a WorkRequest to process and knows how to be chained.

To have a follow-on WorkRequest in a simple two-element chain, call then() on the WorkContinuation before the terminal enqueue() call:

WorkManager.getInstance(getApplicationContext())
  .beginWith(request)
  .then(otherRequest)
  .enqueue();

Now, request will be processed, and if it succeeds, then (and only then) will otherRequest be processed.

How Do We Pass Data Along the Chain?

We provide input to a WorkRequest via its Builder and setInputData(). However, this is input that is created outside the processing of any individual request; it is input that is defined when the chain is defined.

In addition, a Worker can provide output data to factory methods like success() on ListenableWorker.Result. Those factory methods take the same sort of Data object that setInputData() does. The output data can be used in two places:

OK, Where’s the Code?

The UnZIPWork sample module in the Sampler and SamplerJ projects are a variation on the previous example, this time where we have two requests in a chain.

DownloadWorker is largely the same as before, with two differences:

  1. Rather than receiving a filename as input, it decides what the filename will be, as that will merely serve as a temporary file
  2. It passes the path to that file to the next request in the chain via setOutputData()
package com.commonsware.jetpack.work.download;

import android.content.Context;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.ListenableWorker;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okio.BufferedSink;
import okio.Okio;

public class DownloadWorker extends Worker {
  public static final String KEY_URL="url";
  public static final String KEY_RESULTDIR="resultDir";

  public DownloadWorker(@NonNull Context context,
                        @NonNull WorkerParameters workerParams) {
    super(context, workerParams);
  }

  @NonNull
  @Override
  public Result doWork() {
    OkHttpClient client=new OkHttpClient();
    Request request=new Request.Builder()
      .url(getInputData().getString(KEY_URL))
      .build();

    File dir=getApplicationContext().getCacheDir();
    File downloadedFile=new File(dir, "temp.zip");

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

    try (Response response=client.newCall(request).execute()) {
      BufferedSink sink=Okio.buffer(Okio.sink(downloadedFile));

      sink.writeAll(response.body().source());
      sink.close();
    }
    catch (IOException e) {
      Log.e(getClass().getSimpleName(), "Exception downloading file", e);

      return ListenableWorker.Result.failure();
    }

    return ListenableWorker.Result.success(new Data.Builder()
      .putString(UnZIPWorker.KEY_ZIPFILE, downloadedFile.getAbsolutePath())
      .build());
  }
}
package com.commonsware.jetpack.work.download

import android.content.Context
import android.util.Log
import androidx.work.Data
import androidx.work.Worker
import androidx.work.WorkerParameters
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import java.io.File
import java.io.IOException

class DownloadWorker(context: Context, workerParams: WorkerParameters) :
  Worker(context, workerParams) {

  override fun doWork(): Result {
    val client = OkHttpClient()
    val request = Request.Builder()
      .url(inputData.getString(KEY_URL)!!)
      .build()

    val dir = applicationContext.cacheDir
    val downloadedFile = File(dir, "temp.zip")

    if (downloadedFile.exists()) {
      downloadedFile.delete()
    }

    try {
      client.newCall(request).execute().use { response ->
        val sink = downloadedFile.sink().buffer()

        response.body?.let { sink.writeAll(it.source()) }
        sink.close()
      }
    } catch (e: IOException) {
      Log.e(javaClass.simpleName, "Exception downloading file", e)

      return Result.failure()
    }

    return Result.success(
      Data.Builder()
        .putString(UnZIPWorker.KEY_ZIPFILE, downloadedFile.absolutePath)
        .build()
    )
  }

  companion object {
    const val KEY_URL = "url"
    const val KEY_RESULTDIR = "resultDir"
  }
}

We now also have an UnZIPWorker. This expects two pieces of input: the file to unZIP and the directory to unZIP it into. It uses the CWAC-Security library and its ZipUtils.unzip() method, as that safely handles possibly-malicious ZIP files (e.g., zip bombs):

package com.commonsware.jetpack.work.download;

import android.content.Context;
import android.util.Log;
import com.commonsware.cwac.security.ZipUtils;
import java.io.File;
import androidx.annotation.NonNull;
import androidx.work.ListenableWorker;
import androidx.work.Worker;
import androidx.work.WorkerParameters;

public class UnZIPWorker extends Worker {
  public static final String KEY_ZIPFILE="zipFile";
  public static final String KEY_RESULTDIR="resultDir";

  public UnZIPWorker(@NonNull Context context,
                     @NonNull WorkerParameters workerParams) {
    super(context, workerParams);
  }

  @NonNull
  @Override
  public Result doWork() {
    File downloadedFile=new File(getInputData().getString(KEY_ZIPFILE));
    File dir=getApplicationContext().getCacheDir();
    String resultDirData=getInputData().getString(KEY_RESULTDIR);
    File resultDir=new File(dir, resultDirData==null ? "results" : resultDirData);

    try {
      ZipUtils.unzip(downloadedFile, resultDir, 2048, 1024*1024*16);
      downloadedFile.delete();
    }
    catch (Exception e) {
      Log.e(getClass().getSimpleName(), "Exception unZIPing file", e);

      return ListenableWorker.Result.failure();
    }

    return ListenableWorker.Result.success();
  }
}
package com.commonsware.jetpack.work.download

import android.content.Context
import android.util.Log
import androidx.work.ListenableWorker
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.commonsware.cwac.security.ZipUtils
import java.io.File

class UnZIPWorker(context: Context, workerParams: WorkerParameters) :
  Worker(context, workerParams) {

  override fun doWork(): Result {
    val downloadedFile = File(inputData.getString(KEY_ZIPFILE)!!)
    val dir = applicationContext.cacheDir
    val resultDirData = inputData.getString(KEY_RESULTDIR)
    val resultDir = File(dir, resultDirData ?: "results")

    try {
      ZipUtils.unzip(downloadedFile, resultDir, 2048, 1024 * 1024 * 16)
      downloadedFile.delete()
    } catch (e: Exception) {
      Log.e(javaClass.simpleName, "Exception unZIPing file", e)

      return Result.failure()
    }

    return Result.success()
  }

  companion object {
    const val KEY_ZIPFILE = "zipFile"
    const val KEY_RESULTDIR = "resultDir"
  }
}

DownloadViewModel now sets up a request chain using both worker classes:

package com.commonsware.jetpack.work.download;

import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import androidx.work.Constraints;
import androidx.work.Data;
import androidx.work.NetworkType;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkInfo;
import androidx.work.WorkManager;

public class DownloadViewModel extends AndroidViewModel {
  public final MediatorLiveData<WorkInfo> liveWorkStatus=new MediatorLiveData<>();

  public DownloadViewModel(@NonNull Application application) {
    super(application);
  }

  public void doTheDownload() {
    OneTimeWorkRequest downloadWork=
      new OneTimeWorkRequest.Builder(DownloadWorker.class)
        .setConstraints(new Constraints.Builder()
          .setRequiredNetworkType(NetworkType.CONNECTED)
          .setRequiresBatteryNotLow(true)
          .build())
        .setInputData(new Data.Builder()
          .putString(DownloadWorker.KEY_URL,
            "https://commonsware.com/Android/source_1_0.zip")
          .build())
        .addTag("download")
        .build();
    OneTimeWorkRequest unZIPWork=
      new OneTimeWorkRequest.Builder(UnZIPWorker.class)
        .setConstraints(new Constraints.Builder()
          .setRequiresStorageNotLow(true)
          .setRequiresBatteryNotLow(true)
          .build())
        .setInputData(new Data.Builder()
          .putString(DownloadWorker.KEY_RESULTDIR, "unzipped")
          .build())
        .addTag("unZIP")
        .build();

    WorkManager.getInstance(getApplication())
      .beginWith(downloadWork)
      .then(unZIPWork)
      .enqueue();

    final LiveData<WorkInfo> liveOpStatus=
      WorkManager.getInstance(getApplication()).getWorkInfoByIdLiveData(unZIPWork.getId());

    liveWorkStatus.addSource(liveOpStatus, workStatus -> {
      liveWorkStatus.setValue(workStatus);

      if (workStatus.getState().isFinished()) {
        liveWorkStatus.removeSource(liveOpStatus);
      }
    });
  }
}
package com.commonsware.jetpack.work.download

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager

class DownloadViewModel(application: Application) :
  AndroidViewModel(application) {
  val liveWorkStatus = MediatorLiveData<WorkInfo>()

  fun doTheDownload() {
    val downloadWork = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
      .setConstraints(
        Constraints.Builder()
          .setRequiredNetworkType(NetworkType.CONNECTED)
          .setRequiresBatteryNotLow(true)
          .build()
      )
      .setInputData(
        Data.Builder()
          .putString(
            DownloadWorker.KEY_URL,
            "https://commonsware.com/Android/source_1_0.zip"
          )
          .build()
      )
      .addTag("download")
      .build()
    val unZIPWork = OneTimeWorkRequest.Builder(UnZIPWorker::class.java)
      .setConstraints(
        Constraints.Builder()
          .setRequiresStorageNotLow(true)
          .setRequiresBatteryNotLow(true)
          .build()
      )
      .setInputData(
        Data.Builder()
          .putString(DownloadWorker.KEY_RESULTDIR, "unzipped")
          .build()
      )
      .addTag("unZIP")
      .build()

    WorkManager.getInstance(getApplication())
      .beginWith(downloadWork)
      .then(unZIPWork)
      .enqueue()

    val liveOpStatus = WorkManager.getInstance(getApplication())
      .getWorkInfoByIdLiveData(unZIPWork.id)

    liveWorkStatus.addSource(liveOpStatus) { workStatus ->
      liveWorkStatus.value = workStatus

      if (workStatus.state.isFinished) {
        liveWorkStatus.removeSource(liveOpStatus)
      }
    }
  }
}

Of note:

In principle, we should be monitoring both requests’ status updates. If the first request fails for some reason (e.g., HTTP 404 error), the second request will never run. We could do that by calling getWorkInfosLiveData() on the WorkContinuation, which returns a LiveData of a list of WorkInfo objects, one for each request in the chain. That significantly increases the complexity of the sample (e.g., what do we do for data binding in this case?), and so we cheat for the sake of brevity.

How Complex Can This Get?

It can get as complicated as you like:

WorkManager.getIntstance(getApplicationContext())
  .beginWith(lets)
  .then(go)
  .then(crazy)
  .enqueue();

However, while WorkManager is useful for deferrable tasks, it is not a full workflow system:

As a result, at least for the time being, be careful when trying to create complex WorkRequest chains.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.