Techbase Academy
Android · Lesson 36

Background Work with WorkManager

15 phasesWater MeAndroid Studio
Phase 1 of 15
Lesson Introduction

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 a OneTimeWorkRequest and enqueue it.
  • Pass input data and return output data.
  • Add Constraints and a backoff policy.
  • Chain workers, run unique work and schedule periodic work (15-minute minimum).
  • Observe WorkInfo from 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".

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 15
Prerequisite Concepts
  • 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 a Flow.
  • Activity lifecycle and process death (lesson 23). Understanding process death is why WorkManager exists.
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 15
Conceptual Understanding: Choosing the Right Tool

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:

text
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
✏️ Your Task
Practise what you just learned about Conceptual Understanding: Choosing the Right Tool. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 15
Step-by-Step Build 1: Add WorkManager

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:

libs.versions.toml
[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" }
build.gradle.kts
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.)

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Add WorkManager. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 15
Step-by-Step Build 2: Your First Worker

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.

UploadWorker.kt
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 a Result:
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 CoroutineWorker runs on Dispatchers.Default by default. Use withContext(Dispatchers.IO) for blocking input/output.
  • Input and output are small Data objects: 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 a WorkerFactory). 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 CancellationException at suspension points, as in lesson 28. Do not swallow it.

Enqueue it

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Your First Worker. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 15
Step-by-Step Build 3: Constraints, Delay and Backoff

Real background work should be polite. Describe the conditions:

kotlin
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).

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Constraints, Delay and Backoff. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 15
Step-by-Step Build 4: Chains, Unique Work and Periodic Work

Chaining

Run workers in sequence with the output of one as the input of the next:

kotlin
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:

kotlin
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

kotlin
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

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Chains, Unique Work and Periodic Work. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 15
Step-by-Step Build 5: Observing Work and Notifications

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:

kotlin
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:

AndroidManifest.xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Create a channel once (for example in Application.onCreate):

kotlin
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:

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Observing Work and Notifications. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 15
Background Task Inspector

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:

  1. Open View > Tool Windows > App Inspection and select Background Task Inspector.
  2. You see a table of workers with state (Enqueued, Running, Succeeded, Failed, Cancelled), their tags, and constraints.
  3. Click a worker to see its input and output data and its chain graph.
  4. 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:

bash
adb shell cmd jobscheduler run -f com.example.waterme <jobId>

but the Inspector is easier. (The exact menus move between Android Studio versions.)

✏️ Your Task
Practise what you just learned about Background Task Inspector. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 15
Dependencies in Workers

A worker is created by WorkManager, not by you, so you cannot just pass it a repository in its constructor. Two common solutions:

  1. Reach for the container, as you did for ViewModels: (applicationContext as WaterApplication).container.repository. It is simple and perfectly fine for small apps.
  2. 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.

✏️ Your Task
Practise what you just learned about Dependencies in Workers. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 15
Testing Workers

Add the work-testing artifact (done above). It lets you run a worker directly, with no scheduler:

kotlin
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.

✏️ Your Task
Practise what you just learned about Testing Workers. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 15
Practice: The Water Me App

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 WaterReminderWorker per plant (REPLACE policy), with input plant_name and setInitialDelay.
  • The worker posts a notification (if permitted).
  • A WaterRepository interface hides WorkManager so a FakeWaterRepository can replace it in ViewModel tests.
  • The app asks for POST_NOTIFICATIONS the first time a reminder is scheduled.
✏️ Your Task
Practise what you just learned about Practice: The Water Me App. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 15
Guided Practice Exercises
🎯 Your Challenge

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().

kotlin
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.

🎯 Your Challenge

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.

kotlin
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.

🎯 Your Challenge

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.

kotlin
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.

✏️ Your Task

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.

Phase 14 of 15
Common Beginner Mistakes (and How to Fix Them)

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.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 15 of 15
Knowledge Check

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

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Water Me

Project goal: Build the Water Me reminder app from the specification above, with unique work, a notification, an observed status and tests.

Requirements:

  1. A list of five plants. Each row has a Remind me button.
  2. A dialog with three delays (5 seconds, 1 day, 1 week). 5 seconds is for testing.
  3. WaterRepository interface with scheduleReminder(plantName, delay); WorkManagerWaterRepository implementation; FakeWaterRepository for tests.
  4. WaterReminderWorker posting a notification titled with the plant name, after checking the permission and creating the channel.
  5. Ask for POST_NOTIFICATIONS the first time, with an explanation sentence.
  6. A worker test (missing plant name fails; valid input succeeds) and a ViewModel test with the fake repository confirming that choosing a delay calls scheduleReminder with the right duration.

Stretch goal: show a pending indicator on rows whose reminder is scheduled, observed from getWorkInfosForUniqueWorkFlow.

WaterReminderWorker.kt
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"
 }
}
WaterRepository.kt
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
 }
}
WaterViewModelTest.kt
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.

1

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.

2

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”.

3

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.

4

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.

5

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
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close