Some jobs should not happen while the user watches a screen: uploading a batch of photos once the phone is on Wi-Fi, syncing data every few hours, sending a daily "drink water" reminder, blurring an image and saving it. These jobs must still finish if the user switches apps, rotates the phone, or the phone restarts. A coroutine in viewModelScope is not the answer: it dies with the ViewModel.
WorkManager is the Jetpack library for deferrable, guaranteed background work. You describe the work and the conditions (network, charging, battery), and the system runs it at a good time and makes sure it eventually runs, even after a reboot.
In this lesson you will understand when to use WorkManager (and when not to), write workers, pass data in and out, add constraints, retry with backoff, chain and schedule work, observe progress, show notifications, inspect work in Android Studio and test workers. It covers Unit 7 of the official Android Basics with Compose course: Introduction to WorkManager, Implementing WorkManager, Background work with WorkManager, Advanced WorkManager and Testing and Practice: Build Water Me app.
By the end you will be able to:
- Decide between a coroutine, a foreground service, an alarm and WorkManager.
- Write a
CoroutineWorker, build aOneTimeWorkRequestand enqueue it. - Pass input data and return output data.
- Add
Constraintsand a backoff policy. - Chain workers, run unique work and schedule periodic work (15-minute minimum).
- Observe
WorkInfofrom the UI. - Post a notification safely, including the Android 13 permission.
- Use Background Task Inspector and write worker tests.
Big idea: WorkManager is a promise: "this work will run, under these conditions, even if the app is closed". It is not "run this right now".
- Coroutines,
suspend, dispatchers and cancellation (lesson 28). - ViewModel, repositories, the app container and factories (lessons 24 and 30).
- Flow and
stateIn(lesson 33). WorkManager reports status as aFlow. - Activity lifecycle and process death (lesson 23). Understanding process death is why WorkManager exists.
Background work is a crowded field. Pick by asking two questions: does it need to finish even if the app is killed? and must it run at an exact time or right now?
| Need | Tool |
|---|---|
| Work tied to a screen, fine to cancel when the user leaves | Coroutine in viewModelScope (lesson 28) |
| Must finish even if the app closes, can wait for good conditions | WorkManager |
| User-visible ongoing task such as music playback or navigation | Foreground service |
| Must fire at an exact clock time (an alarm clock, a calendar reminder) | AlarmManager exact alarms (needs special permission; use sparingly) |
| Sync that must run now and keep going while visible | Coroutines, with WorkManager as a backup |
WorkManager sits on top of the platform schedulers and picks the right one for the phone's Android version. It handles Doze mode, app standby and battery limits for you. It does not guarantee an exact time: a 9:00 a.m. request may run at 9:07 or later, because the system batches work to save battery.
Three kinds of work
| Kind | Class | Use for |
|---|---|---|
| One-time | OneTimeWorkRequest |
Upload this file; apply this filter |
| Periodic | PeriodicWorkRequest |
Sync every few hours. Minimum interval is 15 minutes. |
| Expedited | setExpedited(...) on a one-time request |
Short, important work that should start right away (with quota limits) |
The main classes:
Worker / CoroutineWorker <- WHAT to do (your code in doWork())
WorkRequest <- WHEN and HOW (constraints, delay, backoff, input)
WorkManager <- enqueues requests and tracks them
WorkInfo <- status: ENQUEUED, RUNNING, SUCCEEDED, FAILED, CANCELLED, BLOCKED
Look up the latest stable WorkManager version on its release page (https://developer.android.com/jetpack/androidx/releases/work). Older versions shipped Kotlin coroutine support in a separate work-runtime-ktx artifact; recent versions include it in work-runtime. Check the release notes for your version, and add what the current documentation shows:
[versions]
work = "<latest stable>"
[libraries]
androidx-work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "work" }
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
dependencies {
implementation(libs.androidx.work.runtime)
androidTestImplementation(libs.androidx.work.testing)
}
(If your version still needs the -ktx artifact for CoroutineWorker, use work-runtime-ktx instead; the compiler will say CoroutineWorker is unresolved.)
A worker is a class with one method, doWork, which runs on a background thread chosen by WorkManager. Use CoroutineWorker so you can call suspend functions.
package com.example.waterme.worker
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
private const val TAG = "UploadWorker"
class UploadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val fileName = inputData.getString(KEY_FILE_NAME) ?: return Result.failure()
return withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Uploading $fileName")
delay(2_000) // pretend upload
Result.success(workDataOf(KEY_RESULT_URL to "https://example.com/files/$fileName"))
} catch (e: java.io.IOException) {
Log.w(TAG, "Network problem, will retry", e)
Result.retry()
}
}
}
companion object {
const val KEY_FILE_NAME = "file_name"
const val KEY_RESULT_URL = "result_url"
}
}
What to know:
doWork()returns aResult:
| Result | Meaning |
|---|---|
Result.success() (optionally with output Data) |
Done. The next worker in a chain starts. |
Result.failure() |
Failed permanently; do not retry. |
Result.retry() |
Try again later using the backoff policy. |
- A
CoroutineWorkerruns onDispatchers.Defaultby default. UsewithContext(Dispatchers.IO)for blocking input/output. - Input and output are small
Dataobjects: key-value maps of primitives, strings and arrays, limited to about 10 KB. Never pass a bitmap or a big list. Pass a file path or a database id and read the real data in the worker. - The worker class needs the exact
(Context, WorkerParameters)constructor: WorkManager creates it using reflection (or aWorkerFactory). Adding your own constructor parameters needs a custom factory (see "Dependencies in workers" below). - Workers can be cancelled. Because this is a coroutine, cancellation arrives as
CancellationExceptionat suspension points, as in lesson 28. Do not swallow it.
Enqueue it
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(workDataOf(UploadWorker.KEY_FILE_NAME to "photo1.jpg"))
.build()
WorkManager.getInstance(applicationContext).enqueue(request)
getInstance(context) returns the singleton WorkManager. Enqueueing is fast and returns immediately; the work runs later, in the background, when its conditions are met.
Real background work should be polite. Describe the conditions:
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.NetworkType
import java.util.concurrent.TimeUnit
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Wi-Fi only; use CONNECTED for any network
.setRequiresBatteryNotLow(true)
.setRequiresCharging(false)
.build()
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(constraints)
.setInitialDelay(10, TimeUnit.MINUTES)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.setInputData(workDataOf(UploadWorker.KEY_FILE_NAME to "photo1.jpg"))
.build()
| Setting | Effect |
|---|---|
setRequiredNetworkType(UNMETERED) |
Run only on Wi-Fi, saving the user's mobile data |
setRequiresBatteryNotLow(true) |
Wait until the battery is not low |
setRequiresCharging(true) |
Wait for charging |
setRequiresStorageNotLow(true) |
Wait until storage is not low |
setInitialDelay |
Do not start before this delay (it is a minimum, not an exact time) |
setBackoffCriteria |
How long to wait before retrying after Result.retry(), either LINEAR or EXPONENTIAL |
If a constraint stops being met while the work runs (for example Wi-Fi drops), WorkManager stops the worker and reschedules it. Design workers to be idempotent: running twice must not cause harm (no duplicate uploads or double charges), because retries and restarts happen.
For many users on prepaid mobile data, defaulting heavy uploads to UNMETERED is a considerate choice. Consider making it a setting (lesson 35).
Chaining
Run workers in sequence with the output of one as the input of the next:
WorkManager.getInstance(context)
.beginWith(OneTimeWorkRequestBuilder<CompressWorker>().build())
.then(OneTimeWorkRequestBuilder<UploadWorker>().build())
.then(OneTimeWorkRequestBuilder<CleanupWorker>().build())
.enqueue()
If one worker fails, the rest of the chain is marked as failed and never runs. You can also run steps in parallel and then continue with beginWith(listOf(a, b)).then(c). Output Data of the previous step becomes input of the next, merged by key, using an InputMerger when several parallel workers feed one (the default is OverwritingInputMerger; use ArrayCreatingInputMerger to collect values from parallel workers into arrays).
Unique work
If the user taps the "Sync" button five times, you do not want five syncs. Make the work unique:
import androidx.work.ExistingWorkPolicy
WorkManager.getInstance(context).enqueueUniqueWork(
"sync_notes",
ExistingWorkPolicy.KEEP, // ignore new requests while one exists
OneTimeWorkRequestBuilder<SyncWorker>().build()
)
| Policy | Behaviour when a work with this name exists |
|---|---|
KEEP |
Keep the existing one, ignore the new request |
REPLACE |
Cancel the existing one, enqueue the new one |
APPEND / APPEND_OR_REPLACE |
Run the new one after the existing one |
Periodic work has its own enqueueUniquePeriodicWork(name, ExistingPeriodicWorkPolicy.KEEP, request). In an Activity or Application you start with KEEP, otherwise each app launch would reschedule the job.
Periodic work
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.ExistingPeriodicWorkPolicy
val daily = PeriodicWorkRequestBuilder<ReminderWorker>(1, TimeUnit.DAYS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("daily_reminder", ExistingPeriodicWorkPolicy.KEEP, daily)
Remember:
- The minimum repeat interval is 15 minutes; shorter values are raised to 15 minutes.
- The interval is approximate and flexible: the system may run it late, and Doze can delay it by hours.
- The work will not fire at a fixed clock time. If the user wants "remind me at 7:30 a.m. exactly", WorkManager is the wrong tool (an exact alarm is, with its permission rules). A common workaround is to compute the delay to the next 7:30 and use a one-time request with
setInitialDelay, accepting some drift.
Cancelling
WorkManager.getInstance(context).cancelWorkById(request.id)
WorkManager.getInstance(context).cancelUniqueWork("daily_reminder")
WorkManager.getInstance(context).cancelAllWorkByTag("uploads")
Give requests a tag with .addTag("uploads") so you can cancel or observe groups.
Observing status with Flow
WorkManager.getWorkInfosForUniqueWorkFlow(name) returns a Flow<List<WorkInfo>>. There are matching functions for work by id and by tag. Convert it to UI state in the repository or ViewModel:
import androidx.work.WorkInfo
sealed interface UploadState {
data object Idle : UploadState
data object Running : UploadState
data class Done(val url: String) : UploadState
data object Failed : UploadState
}
class UploadRepository(private val workManager: WorkManager) {
val state: Flow<UploadState> =
workManager.getWorkInfosForUniqueWorkFlow("upload")
.map { infos ->
val info = infos.firstOrNull() ?: return@map UploadState.Idle
when (info.state) {
WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED, WorkInfo.State.RUNNING -> UploadState.Running
WorkInfo.State.SUCCEEDED ->
UploadState.Done(info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty())
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> UploadState.Failed
}
}
fun start(fileName: String) {
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(workDataOf(UploadWorker.KEY_FILE_NAME to fileName))
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.build()
workManager.enqueueUniqueWork("upload", ExistingWorkPolicy.REPLACE, request)
}
}
WorkInfo.outputData holds what the worker returned with Result.success(data). Because the work state lives in WorkManager's own database, your UI can reconnect to a running job after rotation or after the process was killed and restarted. That is the real strength compared with a coroutine.
Hide WorkManager behind a repository interface (lesson 30), so ViewModel tests can use a fake and never touch the real WorkManager.
Reporting progress
A worker can publish progress with setProgress(workDataOf("percent" to 40)) inside a CoroutineWorker; the UI reads info.progress.getInt("percent", 0) while the state is RUNNING.
Showing a notification from a worker
A background job often ends with a notification. On Android 8 (API 26) and above every notification needs a channel, and on Android 13 (API 33) and above the app also needs the POST_NOTIFICATIONS runtime permission.
Manifest:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Create a channel once (for example in Application.onCreate):
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
const val CHANNEL_ID = "water_reminders"
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL_ID,
"Water reminders",
NotificationManager.IMPORTANCE_DEFAULT
).apply { description = "Reminders to water your plants" }
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
Post from the worker, checking the permission first:
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
fun showReminder(context: Context, plantName: String) {
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!allowed) return // user declined; do not crash
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_water_drop)
.setContentTitle("Time to water $plantName")
.setContentText("Your plant is thirsty.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(plantName.hashCode(), notification)
}
Ask for the permission in the UI at a sensible moment (when the user first schedules a reminder, with a sentence explaining why), using the Activity Result API in Compose (rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission())). Never ask at app start with no context: users deny, and after two denials Android stops showing the dialog.
Long-running and foreground work
Workers have a time limit (around 10 minutes of execution per run). For long jobs you can promote a worker to a foreground service with setForeground(ForegroundInfo(...)) in a CoroutineWorker, which shows an ongoing notification. On Android 14 and above you must declare a foreground service type, and on recent Android versions foreground services started by WorkManager can use up a quota, so check the current "long-running workers" documentation before relying on it. Prefer short workers that can resume.
Android Studio's App Inspection window includes the Background Task Inspector (lesson 44 covers all inspectors). With your app running on a supported device or emulator:
- Open View > Tool Windows > App Inspection and select Background Task Inspector.
- You see a table of workers with state (Enqueued, Running, Succeeded, Failed, Cancelled), their tags, and constraints.
- Click a worker to see its input and output data and its chain graph.
- Right-click to cancel a running job or inspect why it has not run (for example, a constraint not met).
It is the fastest way to answer "why has my periodic job not run?". You can also trigger a job immediately on a debug build with:
adb shell cmd jobscheduler run -f com.example.waterme <jobId>
but the Inspector is easier. (The exact menus move between Android Studio versions.)
A worker is created by WorkManager, not by you, so you cannot just pass it a repository in its constructor. Two common solutions:
- Reach for the container, as you did for ViewModels:
(applicationContext as WaterApplication).container.repository. It is simple and perfectly fine for small apps. - A custom
WorkerFactory(or Hilt's@HiltWorker, lesson 63) that builds workers with their dependencies. More work, better testability.
Keep workers thin: they should delegate to a repository, which is the part you unit test.
Add the work-testing artifact (done above). It lets you run a worker directly, with no scheduler:
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UploadWorkerTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun missingFileName_fails() = runTest {
val worker = TestListenableWorkerBuilder<UploadWorker>(context).build()
val result = worker.doWork()
assertTrue(result is ListenableWorker.Result.Failure)
}
@Test
fun withFileName_succeedsAndReturnsUrl() = runTest {
val worker = TestListenableWorkerBuilder<UploadWorker>(context)
.setInputData(workDataOf(UploadWorker.KEY_FILE_NAME to "a.jpg"))
.build()
val result = worker.doWork()
assertTrue(result is ListenableWorker.Result.Success)
val url = (result as ListenableWorker.Result.Success).outputData.getString(UploadWorker.KEY_RESULT_URL)
assertEquals("https://example.com/files/a.jpg", url)
}
}
TestListenableWorkerBuilder<T> (for CoroutineWorker use TestListenableWorkerBuilder, for plain Worker there is TestWorkerBuilder) creates the worker with the context and input you choose; you call doWork() directly and check the Result. This tests your logic, which is the part you wrote.
To test scheduling behaviour (constraints, delays, chaining), use WorkManagerTestInitHelper.initializeTestWorkManager(context) and TestDriver, which let you pretend that constraints are met or a delay has passed, so you can check a chain without waiting. Search the official testing guide for the current helper names for your WorkManager version.
The official Practice: Build Water Me app asks you to finish an app that reminds users to water plants: a list of plants with a "Remind me" button, where scheduling a reminder sends a notification after a delay (5 seconds, 1 day or 1 week) using WorkManager. Build your own version with this specification:
- Plants are a hard-coded list (name, watering schedule text).
- A bottom sheet or dialog offers three delays: In 5 seconds (for demos), In 1 day, In 1 week.
- Selecting a delay enqueues a unique one-time
WaterReminderWorkerper plant (REPLACEpolicy), with inputplant_nameandsetInitialDelay. - The worker posts a notification (if permitted).
- A
WaterRepositoryinterface hidesWorkManagerso aFakeWaterRepositorycan replace it in ViewModel tests. - The app asks for
POST_NOTIFICATIONSthe first time a reminder is scheduled.
Exercise 1: Compress, then upload
Create a chain of two workers. CompressWorker takes file_name, pretends to compress and outputs compressed_name (the original name plus .zip). UploadWorker reads compressed_name. Show the chain being enqueued as unique work named pipeline, and describe what happens if CompressWorker returns Result.failure().
class CompressWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val name = inputData.getString("file_name") ?: return Result.failure()
delay(1_000) // pretend compression
return Result.success(workDataOf("compressed_name" to "$name.zip"))
}
}
class UploadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val name = inputData.getString("compressed_name") ?: return Result.failure()
delay(2_000) // pretend upload
return Result.success(workDataOf("result" to "uploaded $name"))
}
}
fun startPipeline(context: Context, fileName: String) {
val compress = OneTimeWorkRequestBuilder<CompressWorker>()
.setInputData(workDataOf("file_name" to fileName))
.build()
val upload = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.build()
WorkManager.getInstance(context)
.beginUniqueWork("pipeline", ExistingWorkPolicy.REPLACE, compress)
.then(upload)
.enqueue()
}
The output Data of CompressWorker is merged into the input of UploadWorker, which is why compressed_name appears there. If CompressWorker returns Result.failure(), UploadWorker never starts and is marked as failed too (the chain stops), so the UI sees a failed state for the pipeline.
Exercise 2: Retry with a limit
A worker uploads to a flaky server. Make it retry with exponential backoff but give up after 4 attempts and return Result.failure(), with a clear message in the output data.
override suspend fun doWork(): Result {
return try {
uploadToServer()
Result.success()
} catch (e: java.io.IOException) {
if (runAttemptCount >= 4) {
Result.failure(workDataOf("error" to "Gave up after $runAttemptCount attempts"))
} else {
Result.retry()
}
}
}
runAttemptCount starts at 0 for the first attempt and increases with each retry. With setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) the delays grow (30 s, 60 s, 120 s, and so on, capped by the system maximum). Without a cap, a permanently broken server would be retried for ever, which wastes battery.
Exercise 3: Periodic cleanup
Every day, delete cached files older than 7 days, but only while the phone is charging and the battery is not low. Show how to schedule it exactly once, from Application.onCreate.
class CleanupWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val limit = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000
applicationContext.cacheDir.listFiles()
?.filter { it.isFile && it.lastModified() < limit }
?.forEach { it.delete() }
Result.success()
}
}
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
val request = PeriodicWorkRequestBuilder<CleanupWorker>(1, TimeUnit.DAYS)
.setConstraints(
Constraints.Builder()
.setRequiresCharging(true)
.setRequiresBatteryNotLow(true)
.build()
)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"cache_cleanup",
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
}
KEEP is crucial: onCreate runs on every process start, and without a unique name plus KEEP you would enqueue a new copy each time. The work is idempotent (deleting already-deleted files is harmless), which suits retries.
Open Background Task Inspector, enqueue a worker with a 5-minute initial delay and a charging constraint, and find out from the Inspector why it is still enqueued. Then cancel it from the Inspector.
Mistake #1: Using WorkManager for exact-time tasks. What happens: the alarm fires late. The fix: an exact alarm (with the right permission) for alarm-clock-style needs, or accept approximate timing.
Mistake #2: Enqueueing in onCreate without unique work. What happens: duplicate jobs on every launch. The fix: enqueueUnique... with KEEP.
Mistake #3: Periodic interval under 15 minutes. What happens: silently raised to 15 minutes. The fix: accept the minimum, or use a foreground mechanism for a visible, short-interval task.
Mistake #4: Passing big data through Data. What happens: an IllegalStateException over the 10 KB limit. The fix: pass an id or a file path.
Mistake #5: Non-idempotent workers. What happens: a retry sends the same message twice. The fix: design work so that repeating it is safe (check before acting, use idempotency keys).
Mistake #6: Blocking in doWork with no dispatcher. What happens: the default dispatcher is starved. The fix: withContext(Dispatchers.IO) for blocking calls.
Mistake #7: Posting notifications without the permission and channel. What happens: nothing appears on Android 8 or 13+. The fix: create the channel and check POST_NOTIFICATIONS.
Mistake #8: Forgetting that Result.retry() needs a backoff and a cap. What happens: endless retries. The fix: setBackoffCriteria and a runAttemptCount limit.
Mistake #9: Testing by waiting. What happens: slow, flaky tests. The fix: TestListenableWorkerBuilder and TestDriver.
Mistake #10: Assuming it runs immediately. What happens: a demo fails because Doze delayed the work. The fix: for an on-the-spot job use a coroutine or expedited work, and explain timing to users.
Question 1. When is WorkManager the right choice?
For deferrable work that must run reliably even if the app is closed or the phone restarts, subject to conditions such as network or charging. It is not for exact-time tasks or work that can safely stop when the user leaves the screen.
Question 2. What are the three Result values returned by doWork()?
success (optionally with output data), failure (do not retry) and retry (try again according to the backoff policy).
Question 3. What is the minimum interval for periodic work?
15 minutes. Shorter intervals are raised to that minimum, and timing is approximate.
Question 4. Why use unique work?
To prevent duplicate jobs, for example when the user taps a button repeatedly or when enqueueing from onCreate. The existing-work policy decides whether to keep, replace or append.
Question 5. How do you test a worker's logic without waiting for a scheduler?
Use TestListenableWorkerBuilder from the work-testing artifact to construct the worker with chosen input and call doWork() directly, then assert on the returned Result.
Go deeper: official resources
- Android Basics with Compose, Unit 7: Background work with WorkManager: all five items for this lesson.
- Android Basics with Compose course page
- Long-running workers
Project goal: Build the Water Me reminder app from the specification above, with unique work, a notification, an observed status and tests.
Requirements:
- A list of five plants. Each row has a Remind me button.
- A dialog with three delays (5 seconds, 1 day, 1 week). 5 seconds is for testing.
WaterRepositoryinterface withscheduleReminder(plantName, delay);WorkManagerWaterRepositoryimplementation;FakeWaterRepositoryfor tests.WaterReminderWorkerposting a notification titled with the plant name, after checking the permission and creating the channel.- Ask for
POST_NOTIFICATIONSthe first time, with an explanation sentence. - A worker test (missing plant name fails; valid input succeeds) and a ViewModel test with the fake repository confirming that choosing a delay calls
scheduleReminderwith the right duration.
Stretch goal: show a pending indicator on rows whose reminder is scheduled, observed from getWorkInfosForUniqueWorkFlow.
package com.example.waterme.worker
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.example.waterme.R
class WaterReminderWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val plantName = inputData.getString(KEY_PLANT_NAME) ?: return Result.failure()
showNotification(plantName)
return Result.success()
}
private fun showNotification(plantName: String) {
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
applicationContext, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
if (!allowed) return
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_water_drop)
.setContentTitle("Water your $plantName")
.setContentText("It is time for a drink.")
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(applicationContext).notify(plantName.hashCode(), notification)
}
companion object {
const val KEY_PLANT_NAME = "plant_name"
const val CHANNEL_ID = "water_reminders"
}
}
package com.example.waterme.data
import android.content.Context
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.example.waterme.worker.WaterReminderWorker
import java.util.concurrent.TimeUnit
interface WaterRepository {
fun scheduleReminder(plantName: String, delayMillis: Long)
}
class WorkManagerWaterRepository(context: Context) : WaterRepository {
private val workManager = WorkManager.getInstance(context)
override fun scheduleReminder(plantName: String, delayMillis: Long) {
val request = OneTimeWorkRequestBuilder<WaterReminderWorker>()
.setInitialDelay(delayMillis, TimeUnit.MILLISECONDS)
.setInputData(workDataOf(WaterReminderWorker.KEY_PLANT_NAME to plantName))
.addTag("water")
.build()
workManager.enqueueUniqueWork("water_$plantName", ExistingWorkPolicy.REPLACE, request)
}
}
class FakeWaterRepository : WaterRepository {
val scheduled = mutableListOf<Pair<String, Long>>()
override fun scheduleReminder(plantName: String, delayMillis: Long) {
scheduled += plantName to delayMillis
}
}
class WaterViewModelTest {
@Test
fun choosingOneDay_schedulesTwentyFourHours() {
val fake = FakeWaterRepository()
val vm = WaterViewModel(fake)
vm.onReminderChosen("Basil", ReminderDelay.ONE_DAY)
assertEquals(listOf("Basil" to TimeUnit.DAYS.toMillis(1)), fake.scheduled)
}
}
Notes: REPLACE per plant means tapping the button twice for the same plant keeps only the newest reminder, while different plants have independent reminders. The 5-second option is for demos; in practice the system may start the worker a little later. ReminderDelay is an enum with FIVE_SECONDS, ONE_DAY and ONE_WEEK, each carrying a duration in milliseconds, and WaterViewModel.onReminderChosen simply calls repository.scheduleReminder(plant, delay.millis). Create the notification channel once in your Application.onCreate using the function shown in the lesson. For the stretch goal, expose workManager.getWorkInfosByTagFlow("water") through the repository and map ENQUEUED entries to a set of pending plant names; the unique work name encodes the plant, so you can read the name from the work's tags if you add addTag(plantName) too.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 36 project”.
On github.com click New repository, name it android-lesson-36-background-work-with-workmanager, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 36 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Choosing the Right Tool
- Step-by-Step Build 1: Add WorkManager
- Step-by-Step Build 2: Your First Worker
- Step-by-Step Build 3: Constraints, Delay and Backoff
- Step-by-Step Build 4: Chains, Unique Work and Periodic Work
- Step-by-Step Build 5: Observing Work and Notifications
- Background Task Inspector
- Dependencies in Workers
- Testing Workers
- Practice: The Water Me App
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check