Advanced WorkManager

1. Introduction

This codelab teaches advanced WorkManager concepts. It builds on the basic material covered in the Background Work with WorkManager codelab.

Other resources available to get familiar with WorkManager are:

What you'll build

In this codelab you'll be working on Blur-O-Matic, an app that blurs photos and images and saves the result to a file. If you have already completed the Background Work with WorkManager codelab, this is a similar sample app (the one difference is that this sample app allows you to select your own image from the photos gallery to blur). Here you will add some features to the code:

  1. Custom configuration
  2. Use the Progress API to update the UI while your work is executed
  3. Test your Workers

What you'll need

To do this codelab, you'll need the latest Android Studio stable version.

You should also be familiar with LiveData, ViewModel and View Binding. If you're new to these classes, check out the Android Lifecycle-aware components Codelab (specifically for ViewModel and LiveData) or Room with a View Codelab (an introduction to Architecture Components).

If you get stuck at any point

If you get stuck with this codelab at any point, or if you want to look at the final state of the code, you can

Or, if you prefer, you can clone the completed WorkManager codelab from GitHub:

$ git clone -b advanced https://github.com/googlecodelabs/android-workmanager

2. Getting set up

Step 1 - Download the Code

Click the following link to download the version of the code to follow along this codelab:

Or if you prefer, you can clone the codelab from GitHub:

$ git clone -b advanced_start https://github.com/googlecodelabs/android-workmanager 

Step 2 - Run the app

Run the app. You should see the following screens. Make sure to grant the app permission to access your photos when prompted.

Starting screen of app that prompts user to select an image from the photo gallery.

Screen shown to user after image has been selected from gallery, with radio buttons for desired blur amount and Go button to start blur process.

You can select an image and get to the next screen, which has radio buttons where you can select how blurry you'd like your image to be. Pressing the Go button will blur and save the image. During the blurring the app shows a Cancel button to let you end the work.

WorkManager request in progress with notification showing on top and loading spinner on bottom.

The starting code contains:

  • WorkerUtils: This class contains the code for actually blurring, and a few convenience methods which you'll use later to display Notifications and slow down the app.
  • BlurApplication: The application class with a simple onCreate() method to initialize the Timber logging system for debug builds.
  • BlurActivity: The activity which shows the image and includes radio buttons for selecting blur level.
  • BlurViewModel: This view model stores all of the data needed to display the BlurActivity. It will also be the class where you start the background work using WorkManager.
  • Workers/CleanupWorker: This Worker always deletes the temporary files if they exist.
  • Workers/BlurWorker: This Worker blurs the image passed as input data with a URI and returns the URI of the temporary file.
  • Workers/SaveImageToFileWorker: This Worker takes as input the URI of the temporary image and returns the URI of the final file.
  • Constants: A static class with some constants you'll use during the codelab.
  • SelectImageActivity: The first activity which allows you to select an image.
  • res/activity_blur.xml and res/activity_select.xml: The layout files for each activity.

You'll be making code changes in the following classes: BlurApplication, BlurActivity, BlurViewModel, and BlurWorker.

3. Add WorkManager to your app

WorkManager requires the gradle dependency below. These have been already included in the files:

app/build.gradle

dependencies {
    implementation "androidx.work:work-runtime-ktx:$versions.work"
}

You should get the most current version of work-runtime from the WorkManager release page and put a version for the latest stable release in, or use the one below:

build.gradle

versions.work = "2.7.1"

Make sure to click on Sync Now to sync your project with the changed Gradle files.

4. Add a WorkManager's custom configuration

In this step you will add a custom configuration to the app to modify WorkManager's logging level for debug builds.

Step 1 - Disable the default initialization

As described in the Custom WorkManager configuration and initialization documentation, you have to disable the default initialization in your AndroidManifest.xml file, by removing the node that is merged automatically from the WorkManager library by default.

To remove this node, you can add a new provider node to your AndroidManifest.xml, as shown below:

AndroidManifest.xml

<application

...

    <provider
        android:name="androidx.work.impl.WorkManagerInitializer"
        android:authorities="${applicationId}.workmanager-init"
        tools:node="remove" />
</application>

You'll also need to add the tools namespace to the manifest. The complete file with these changes will be:

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Google LLC.        
   SPDX-License-Identifier: Apache-2.0 -->

<manifest package="com.example.background"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <application
        android:name=".BlurApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">

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

        <activity android:name=".BlurActivity" />

        <!-- ADD THE FOLLOWING NODE -->
        <provider
            android:name="androidx.work.impl.WorkManagerInitializer"
            android:authorities="${applicationId}.workmanager-init"
            tools:node="remove" />
    </application>
</manifest>

Step 2 - Add a Configuration.Provider to the Application class

You can use an on-demand initialization by implementing WorkManager's Configuration.Provider interface in your Application class. The first time your application gets the WorkManager's instance using getInstance(context), WorkManager initializes itself using the configuration returned by getWorkManagerConfiguration().

BlurApplication.kt

class BlurApplication : Application(), Configuration.Provider {

    override fun getWorkManagerConfiguration(): Configuration = 

        Configuration.Builder()
                     .setMinimumLoggingLevel(android.util.Log.DEBUG)
                     .build()
...
}

With this change, WorkManager runs with logging set to DEBUG.

A better option is probably to set up WorkManager in this way only for debug builds of your app, using something like:

BlurApplication.kt

class BlurApplication() : Application(), Configuration.Provider {

    override fun getWorkManagerConfiguration(): Configuration {
        return if (BuildConfig.DEBUG) {
            Configuration.Builder()
                    .setMinimumLoggingLevel(android.util.Log.DEBUG)
                    .build()
        } else {
            Configuration.Builder()
                    .setMinimumLoggingLevel(android.util.Log.ERROR)
                    .build()
        }
    }

...
}

The complete BlurApplication.kt then becomes:

BlurApplication.kt

/* Copyright 2020 Google LLC.        
   SPDX-License-Identifier: Apache-2.0 */

package com.example.background

import android.app.Application
import androidx.work.Configuration
import timber.log.Timber
import timber.log.Timber.DebugTree

class BlurApplication() : Application(), Configuration.Provider {

    override fun getWorkManagerConfiguration(): Configuration {
        return if (BuildConfig.DEBUG) {
            Configuration.Builder()
                    .setMinimumLoggingLevel(android.util.Log.DEBUG)
                    .build()
        } else {
            Configuration.Builder()
                    .setMinimumLoggingLevel(android.util.Log.ERROR)
                    .build()
        }
    }

    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            Timber.plant(DebugTree())
        }
    }
}

Step 3 - Run the app in debug mode

WorkManager is now configured in such a way that your debug builds log all the messages coming from the library.

Running the app, you can see the logs in Android Studio's logcat tab:

5f3522812d1bfb18.png

Step 4 - What can you configure?

The full list of parameters is in WorkManager's reference guide for the Configuration.Builder. Pay attention to two additional parameters:

  • WorkerFactory
  • JobId range

Modifying the WorkerFactory allows adding other parameters to your Worker's constructor. You can find more information about how to implement a custom WorkerFactory in this Customizing WorkManager article. If you are using WorkManager as well as the JobScheduler API in your app, it's a good idea to customize the JobId range to avoid that same JobId range being used by the two APIs.

Sharing WorkManager's Progress

WorkManager v2.3 added the functionality to share progress information from your Worker to your app using the setProgressAsync() (or setProgress() when used from a CoroutineWorker). This information can be observed through a WorkInfo, and is intended to be used to provide feedback in the UI to the user. The progress data is then cancelled when the worker reaches a final state (SUCCEEDED, FAILED, or CANCELLED). To know more about how to publish and listen for progress, read Observing intermediate Worker progress.

What you are going to do now is to add a progress bar in the UI so that, if the app is in foreground, the user can see how the blurring is proceeding. The end result will be like:

WorkManager request in progress, as indicated by progress bar shown on the bottom of the screen.

Step 1 - Modify the ProgressBar

To modify the ProgressBar in the layout you need to delete the android:indeterminate="true" parameter, add the style style="@android:style/Widget.ProgressBar.Horizontal", and set an initial value with android:progress="0". You also need to set the LinearLayout orientation to "vertical":

app/src/main/res/layout/activity_blur.xml

<LinearLayout
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical">
    <ProgressBar
        android:id="@+id/progress_bar"
        style="@android:style/Widget.ProgressBar.Horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:progress="0"
        android:visibility="gone"
        android:layout_gravity="center_horizontal"
        />

    <Button
        android:id="@+id/cancel_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/cancel_work"
        android:visibility="gone"
        />
</LinearLayout>

The other needed change is to be sure that the ProgressBar does restart at the initial position. You can do this by updating the showWorkFinished() function in the BlurActivity.kt file:

app/src/main/java/com/example/background/BlurActivity.kt

/**
 * Shows and hides views for when the Activity is done processing an image
 */
private fun showWorkFinished() {
    with(binding) {
        progressBar.visibility = View.GONE
        cancelButton.visibility = View.GONE
        goButton.visibility = View.VISIBLE
        progressBar.progress = 0 // <-- ADD THIS LINE
    }
}

Step 2 - Observe the progress information in the ViewModel

There is already an observer in the BlurViewModel file that checks when your chain is completed. Add a new one that observes the progress posted by BlurWorker.

First, add a couple of constants to track this at the end of the Constants.kt file:

app/src/main/java/com/example/background/Constants.kt

// Progress Data Key
const val PROGRESS = "PROGRESS"
const val TAG_PROGRESS = "TAG_PROGRESS"

The next step is to add this tag to the BlurWorker's WorkRequest in the BlurViewModel.kt file so that you can retrieve its WorkInfo. From that WorkInfo, you can retrieve the worker's progress information:

app/src/main/java/com/example/background/BlurViewModel.kt

// Add WorkRequests to blur the image the number of times requested
for (i in 0 until blurLevel) {
    val blurBuilder = OneTimeWorkRequestBuilder<BlurWorker>()

    // Input the Uri if this is the first blur operation
    // After the first blur operation the input will be the output of previous
    // blur operations.
    if (i == 0) {
        blurBuilder.setInputData(createInputDataForUri())
    }

    blurBuilder.addTag(TAG_PROGRESS) // <-- ADD THIS
    continuation = continuation.then(blurBuilder.build())
}

Add a new LiveData to the BlurViewModel.kt file that tracks this WorkRequest, and initialize the LiveData in the init block:

app/src/main/java/com/example/background/BlurViewModel.kt

class BlurViewModel(application: Application) : AndroidViewModel(application) {

    internal var imageUri: Uri? = null
    internal var outputUri: Uri? = null
    internal val outputWorkInfoItems: LiveData<List<WorkInfo>>
    internal val progressWorkInfoItems: LiveData<List<WorkInfo>> // <-- ADD THIS
    private val workManager: WorkManager = WorkManager.getInstance(application)

    init {
        // This transformation makes sure that whenever the current work Id changes the WorkStatus
        // the UI is listening to changes
        outputWorkInfoItems = workManager.getWorkInfosByTagLiveData(TAG_OUTPUT)
        progressWorkInfoItems = workManager.getWorkInfosByTagLiveData(TAG_PROGRESS) // <-- ADD THIS
    }

...
}

Step 3 - Observe the LiveData in the Activity

You can now use this LiveData in BlurActivity to observe all the published progress. First register a new LiveData observer at the end of the onCreate() method:

app/src/main/java/com/example/background/BlurActivity.kt

// Show work status
viewModel.outputWorkInfoItems.observe(this, outputObserver())

// ADD THE FOLLOWING LINES
// Show work progress
viewModel.progressWorkInfoItems.observe(this, progressObserver())

Now you can check the WorkInfo received in the observer to see if there is any progress information, and update the ProgressBar accordingly:

app/src/main/java/com/example/background/BlurActivity.kt

private fun progressObserver(): Observer<List<WorkInfo>> {
    return Observer { listOfWorkInfo ->
        if (listOfWorkInfo.isNullOrEmpty()) {
            return@Observer
        }

        listOfWorkInfo.forEach { workInfo ->
            if (WorkInfo.State.RUNNING == workInfo.state) {
                val progress = workInfo.progress.getInt(PROGRESS, 0)
                binding.progressBar.progress = progress
            }
        }

    }
}

Step 4 - Publish Progress from BlurWorker

All the pieces needed to display the progress information are now in place. It's time to add the actual publishing of the progress information to BlurWorker.

This example simply simulates some lengthy process in our doWork() function so that it can publish progress information over a defined amount of time.

The change here is to swap a single delay with 10 smaller ones, setting a new progress at each iteration:

app/src/main/java/com/example/background/workers/BlurWorker.kt

override fun doWork(): Result {
    val appContext = applicationContext

    val resourceUri = inputData.getString(KEY_IMAGE_URI)

    makeStatusNotification("Blurring image", appContext)
    // sleep()
    (0..100 step 10).forEach {
        setProgressAsync(workDataOf(PROGRESS to it))
        sleep()
    }

...
}

As the original delay was 3 seconds, it is probably a good idea to also reduce it by a factor of ten to 0.3 seconds:

app/src/main/java/com/example/background/Constants.kt

// const val DELAY_TIME_MILLIS: Long = 3000
const val DELAY_TIME_MILLIS: Long = 300

Step 5 - Run

Running the application at this point it should show the ProgressBar populated with the messages coming from BlurWorker.

5. Testing WorkManager

Testing is an important component of every application and, when introducing a library like WorkManager, it's important to provide the tools to easily test your code.

With WorkManager, we also made some helpers available to easily test your Workers. To know more about how to create tests for your workers you can refer to WorkManager documentation on testing.

In this section of the codelab we're going to introduce some tests for our Worker classes showing some of the common use cases.

First, we want to provide an easy way to setup our tests, to do this we can create a TestRule that setup WorkManager:

  • Add Dependencies
  • Create WorkManagerTestRule and TestUtils
  • Create Test for CleanupWorker
  • Create Test for BlurWorker

Assuming that you already created the AndroidTest folder in your project, we need to add some dependencies to use in our tests:

app/build.gradle

androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
androidTestImplementation "androidx.test.ext:junit:1.1.3"
androidTestImplementation "androidx.test:rules:1.4.0"
androidTestImplementation "androidx.test:runner:1.4.0"
androidTestImplementation "androidx.work:work-testing:$versions.work"

We can now start putting together the pieces with a TestRule that we can use in our tests:

app/src/androidTest/java/com/example/background/workers/WorkManagerTestRule.kt

/* Copyright 2020 Google LLC.        
   SPDX-License-Identifier: Apache-2.0 */

package com.example.background.workers

import android.content.Context
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.WorkManager
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import org.junit.rules.TestWatcher
import org.junit.runner.Description

class WorkManagerTestRule : TestWatcher() {
    lateinit var targetContext: Context
    lateinit var testContext: Context
    lateinit var configuration: Configuration
    lateinit var workManager: WorkManager

    override fun starting(description: Description?) {
        targetContext = InstrumentationRegistry.getInstrumentation().targetContext
        testContext = InstrumentationRegistry.getInstrumentation().context
        configuration = Configuration.Builder()
                // Set log level to Log.DEBUG to make it easier to debug
                .setMinimumLoggingLevel(Log.DEBUG)
                // Use a SynchronousExecutor here to make it easier to write tests
                .setExecutor(SynchronousExecutor())
                .build()

        // Initialize WorkManager for instrumentation tests.
        WorkManagerTestInitHelper.initializeTestWorkManager(targetContext, configuration)
        workManager = WorkManager.getInstance(targetContext)
    }
}

As we will need this test image on the device (where the tests are going to be run) we can create a couple of helper functions to use in our tests:

app/src/androidTest/java/com/example/background/workers/TestUtils.kt

/* Copyright 2020 Google LLC.        
   SPDX-License-Identifier: Apache-2.0 */

package com.example.background.workers

import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import com.example.background.OUTPUT_PATH
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.util.UUID

/**
 * Copy a file from the asset folder in the testContext to the OUTPUT_PATH in the target context.
 * @param testCtx android test context
 * @param targetCtx target context
 * @param filename source asset file
 * @return Uri for temp file
 */
@Throws(Exception::class)
fun copyFileFromTestToTargetCtx(testCtx: Context, targetCtx: Context, filename: String): Uri {
    // Create test image
    val destinationFilename = String.format("blur-test-%s.png", UUID.randomUUID().toString())
    val outputDir = File(targetCtx.filesDir, OUTPUT_PATH)
    if (!outputDir.exists()) {
        outputDir.mkdirs()
    }
    val outputFile = File(outputDir, destinationFilename)

    val bis = BufferedInputStream(testCtx.assets.open(filename))
    val bos = BufferedOutputStream(FileOutputStream(outputFile))
    val buf = ByteArray(1024)
    bis.read(buf)
    do {
        bos.write(buf)
    } while (bis.read(buf) != -1)
    bis.close()
    bos.close()

    return Uri.fromFile(outputFile)
}

/**
 * Check if a file exists in the given context.
 * @param testCtx android test context
 * @param uri for the file
 * @return true if file exist, false if the file does not exist of the Uri is not valid
 */
fun uriFileExists(targetCtx: Context, uri: String?): Boolean {
    if (uri.isNullOrEmpty()) {
        return false
    }

    val resolver = targetCtx.contentResolver

    // Create a bitmap
    try {
        BitmapFactory.decodeStream(
                resolver.openInputStream(Uri.parse(uri)))
    } catch (e: FileNotFoundException) {
        return false
    }
    return true
}

Once we have down this work, we can start writing our tests.

First we test our CleanupWorker, to check that it actually deletes our files. To do this,copy the test image on the device in the test, and then check if it's there after the CleanupWorker has been executed:

app/src/androidTest/java/com/example/background/workers/CleanupWorkerTest.kt

/* Copyright 2020 Google LLC.        
   SPDX-License-Identifier: Apache-2.0 */

package com.example.background.workers

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import org.hamcrest.CoreMatchers.`is`
import org.junit.Assert.assertThat
import org.junit.Rule
import org.junit.Test

class CleanupWorkerTest {

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()
    @get:Rule
    var wmRule = WorkManagerTestRule()

    @Test
    fun testCleanupWork() {
        val testUri = copyFileFromTestToTargetCtx(
                wmRule.testContext, wmRule.targetContext, "test_image.png")
        assertThat(uriFileExists(wmRule.targetContext, testUri.toString()), `is`(true))

        // Create request
        val request = OneTimeWorkRequestBuilder<CleanupWorker>().build()

        // Enqueue and wait for result. This also runs the Worker synchronously
        // because we are using a SynchronousExecutor.
        wmRule.workManager.enqueue(request).result.get()
        // Get WorkInfo
        val workInfo = wmRule.workManager.getWorkInfoById(request.id).get()

        // Assert
        assertThat(uriFileExists(wmRule.targetContext, testUri.toString()), `is`(false))
        assertThat(workInfo.state, `is`(WorkInfo.State.SUCCEEDED))
    }
}

You can now run this test from Android Studio from the Run menu, or using the green rectangle on the left side of your test class:

be955a84b5b00400.png

You can also run your tests from the command line using the command ./gradlew cAT from the root folder of your project.

You should see that your tests executes correctly.

Next we can test our BlurWorker. This worker expects an input data with the URI of the image to process, so we can build a couple of tests:one that checks that the worker fails if there's no input URI, and one that actually processes the input image.

app/src/androidTest/java/com/example/background/workers/BlurWorkerTest.kt

/* Copyright 2020 Google LLC.        
   SPDX-License-Identifier: Apache-2.0 */

package com.example.background.workers

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.workDataOf
import org.hamcrest.CoreMatchers.`is`
import org.junit.Assert.assertThat
import org.junit.Rule
import com.example.background.KEY_IMAGE_URI
import org.junit.Test

class BlurWorkerTest {

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()
    @get:Rule
    var wmRule = WorkManagerTestRule()

    @Test
    fun testFailsIfNoInput() {
        // Define input data

        // Create request
        val request = OneTimeWorkRequestBuilder<BlurWorker>().build()

        // Enqueue and wait for result. This also runs the Worker synchronously
        // because we are using a SynchronousExecutor.
        wmRule.workManager.enqueue(request).result.get()
        // Get WorkInfo
        val workInfo = wmRule.workManager.getWorkInfoById(request.id).get()

        // Assert
        assertThat(workInfo.state, `is`(WorkInfo.State.FAILED))
    }

    @Test
    @Throws(Exception::class)
    fun testAppliesBlur() {
        // Define input data
        val inputDataUri = copyFileFromTestToTargetCtx(
                wmRule.testContext,
                wmRule.targetContext,
                "test_image.png")
        val inputData = workDataOf(KEY_IMAGE_URI to inputDataUri.toString())

        // Create request
        val request = OneTimeWorkRequestBuilder<BlurWorker>()
                .setInputData(inputData)
                .build()

        // Enqueue and wait for result. This also runs the Worker synchronously
        // because we are using a SynchronousExecutor.
        wmRule.workManager.enqueue(request).result.get()
        // Get WorkInfo
        val workInfo = wmRule.workManager.getWorkInfoById(request.id).get()
        val outputUri = workInfo.outputData.getString(KEY_IMAGE_URI)

        // Assert
        assertThat(uriFileExists(wmRule.targetContext, outputUri), `is`(true))
        assertThat(workInfo.state, `is`(WorkInfo.State.SUCCEEDED))
    }
}

If you run these tests, they should both succeed.

6. Congratulations

Congratulations! You've finished the Blur-O-Matic app, and in the process you learned how to:

  • Create a custom configuration
  • Publish progress from your Worker
  • Display work progress in the UI
  • Write tests for your Workers

Excellent "work"! To see the end state of the code and all the changes, check out:

Or if you prefer, you can clone the WorkManager's codelab from GitHub:

$ git clone -b advanced https://github.com/googlecodelabs/android-workmanager

WorkManager supports a lot more than we could cover in this codelab. To learn more, head over to the WorkManager documentation.