Job Scheduling in Android

Amit Gupta
15 min readApr 11, 2021

When to use WorkManager:

1.) If Task is defferable , repeated with no exact time, Need to be completed sure even on Reboot, Then use WorkManager.

2.) If any work is triggered online, use Push Notification to trigger WorkManager Work.

3.) If you want to send Data instantly from Mobile to Server one time then use OneTimeWorkRequestBuilder , otherwise use Foreground Service instead of WorkManager for sending continuous data upload to server.

4.) Since WorkManager has a limit for running background work in 10 mins , if any work is heavy which may take longer than 10 mins , use setForeground() in doWork() for starting Foreground service from WorkManager Background thread. Ex: App Backup and Restore Data.

Both of these strategy(point 3 and 4 ) will ensure the data upload at server side. And if it is heavy upload work which may take longer than 10 minutes , please use Foreground Service definitely.

5.) Unfortunately, you cannot schedule a work at specific time as of now. If you have time critical implementation then you should use AlarmManager to set alarm that can fire while in Doze to by using setAndAllowWhileIdle() or setExactAndAllowWhileIdle().

6.) Use One Time WorkManager to send GA events, Logs Events and Analytics Events to backend services which can upload event data in form of 10 events batch from Room Database, Database is used to store the events data so that it does not lose when app is killed or removed.(Google GA and Firebase Crash Logs may be using the same thing.)

We can use Unique Request with Append WorkPolicy so that all events are saved in DB in the same WorkManager Job if already running and start upload after collecting 10 events.

https://stackoverflow.com/questions/57789689/how-to-queue-tasks-for-worker-workermanager-api

One Time Syncing can be done in 8 hrs for data like Dynamic Localised Strings, Dynamic Url and Flags update , (Firebase Config or GTM of Google may be using the same thing) which can be updated at runtime from backend service. It requires one time Since Once user opens the app only then New String Data is relevant to reflected so It can be started once at app launch with 8 hrs gap.

7.) Use Periodic WorkManager to sync application data with a server using Room Database so that syncing status can be saved in DB. Like Whats app and Facebook Data Backup Syncup.

8.) If Some work is required which should be driven by backend , then send push and use WorkManager and if it may take longer than 10 mins use Foreground service in WorkManager Job.

9.) If job is to be performed in background but only till the app instance is live then use Kotlin Coroutines to perform it in background.

10.) If Job need to be performed continuously use foreground service like Audio or Music Media Player, it is considered to be foreground process if it is started from activity or component which was visible initially and foreground service is considered to be background process if it is started from background job doWork() method from WorkManager.

11.) If we want to get location updates after every 15 minutes then we can use Workmanager which may be deferable but if we need strict location updates of the user frequently then we need to choose Foreground service.

If we want to continuously monitor the location without user action then first trigger Unique Periodic WorkManager Job either by Push Notification or by user action once and then start Foreground service from the WorkManager Job. After every Unique Periodic interval first stop the running foreground service. You can give action button on notification bar so that user can cancel or stop that foreground service.

You create new Work and specify which Worker should do the work with what Arguments under which Constraints. WorkManager saves the work in DB using Room and immediately enqueue the Work. It’s choosing the best possible scheduler (Jobscheduler, JobDispatcher, Executor aka GreedyScheduler, AlarmManager) and call for doWork() method. The result published over LiveData, and the output is available using Arguments

Immediate tasks

For tasks that should be executed immediately and need continued processing, even if the user puts the application in background or the device restarts, WorkManager is recommended since its support for long-running tasks.

In specific cases, such as with media playback or active navigation, you might want to use foreground Services directly.

Deferred tasks

Every task that is not directly connected to a user interaction and can run at any time in the future can be deferred. The recommended solution for deferred tasks is WorkManager.

WorkManager makes it easy to schedule deferrable, asynchronous tasks that are expected to run even if the app exits or the device restarts.

Exact tasks

A task that needs to be executed at an exact point in time can use AlarmManager.

What is WorkManager:

Scheduled work is stored in an internally managed SQLite database and WorkManager takes care of ensuring that this work persists and is rescheduled across device reboots.

WorkManager is intended for work that is deferrable — that is, not required to run immediately — and required to run reliably even if the app exits or the device restarts.

For example:

  • Sending logs or analytics to backend services
  • Periodically syncing application data with a server
class UploadWorker(appContext: Context, workerParams: WorkerParameters):
Worker(appContext, workerParams) {
override fun doWork(): Result {
// Do the work here--in this case, upload the images.
uploadImages()
// Indicate whether the work finished successfully with the Result
return Result.success()
}
}
val uploadWorkRequest: WorkRequest =
OneTimeWorkRequestBuilder<UploadWorker>()
.build()
WorkManager
.getInstance(myContext)
.enqueue(uploadWorkRequest)

The Result returned from doWork() informs the WorkManager service whether the work succeeded and, in the case of failure, whether or not the work should be retried.

  • Result.success(): The work finished successfully.
  • Result.failure(): The work failed.
  • Result.retry(): The work failed and should be tried at another time according to its retry policy.

There can be 3 types of WorkManager:

1.) OneTime Work

2.) Periodic Work

3.) Chaining Work(Always used with OneTime Work and can’t be used with Periodic Work)

One-time work states

In the ENQUEUED state, your work is eligible to run as soon as its Constraints and initial delay timing requirements are met. From there it moves to a RUNNING state and then depending on the outcome of the work it may move to SUCCEEDED, FAILED, or possibly back to ENQUEUED if the result is retry. At any point in the process, work can be cancelled, at which point it will move to the CANCELLED state.

SUCCEEDED, FAILED and CANCELLED all represent a terminal state for this work. If your work is in any of these states, WorkInfo.State.isFinished() returns true.

Periodic work states

Periodic Work:

For example, you may want to periodically backup your data, download fresh content in your app, or upload logs to a server.

In below example, the work is scheduled with a one hour interval.

The interval period is defined as the minimum time between repetitions. The exact time that the worker is going to be executed depends on the constraints that you are using in your WorkRequest object and on the optimizations performed by the system.

The minimum repeat interval that can be defined is 15 minutes

val saveRequest =
PeriodicWorkRequestBuilder<SaveImageToFileWorker>(1, TimeUnit.HOURS)
// Additional configuration
.build()
val myUploadWork = PeriodicWorkRequestBuilder<SaveImageToFileWorker>(
1, TimeUnit.HOURS, // repeatInterval (the period cycle)
15, TimeUnit.MINUTES) // flexInterval
.build()

We can give Flex Interval which gives us benifit to start the job little bit sooner in flex window , The following is an example of periodic work that can run during the last 15 minutes of every one hour period.

Work Constraints:

val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).setRequiresCharging(true).setBatteryNotLow(true).setDeviceIdle(true).setStorageNotLow(true).build()val myWorkRequest: WorkRequest =OneTimeWorkRequestBuilder<MyWork>().setConstraints(constraints).setBackoffCriteria(BackoffPolicy.LINEAR, // or EXPONENTIALOneTimeWorkRequest.MIN_BACKOFF_MILLIS, // min 10 secTimeUnit.MILLISECONDS).setInitialDelay(10, TimeUnit.MINUTES)//First time Initial Delay if Periodic Job.addTag("cleanup") // used to identify job by tag.setInputData(workDataOf("IMAGE_URI" to "http://..."
))// Used to send Input data once job started
.build()

BY using Tag, For example, WorkManager.cancelAllWorkByTag(String) cancels all Work Requests with a particular tag, and WorkManager.getWorkInfosByTag(String) returns a list of the WorkInfo objects which can be used to determine the current work state.

class UploadWork(appContext: Context, workerParams: WorkerParameters)
: Worker(appContext, workerParams) {
override fun doWork(): Result {
val imageUriInput =
inputData.getString("IMAGE_URI") ?: return Result.failure()
uploadFile(imageUriInput)
return Result.success()
}
...
}

EnQueue Work:

val myWork: WorkRequest = // ... OneTime or PeriodicWork
WorkManager.getInstance(requireContext()).enqueue(myWork)

Use caution when enqueuing work to avoid duplication. For example, an app might try to upload its logs to a backend service every 24 hours. If you aren’t careful, you might end up enqueueing the same task many times, even though the job only needs to run once. To achieve this goal, you can schedule the work as unique work.

Unique Work:

WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"sendLogs",
ExistingPeriodicWorkPolicy.KEEP,
sendLogsWorkRequest

Now, if the code runs while a sendLogs job is already in the queue, the existing job is kept and no new job is added.

For one-time work, you provide an ExistingWorkPolicy, which supports 4 options for handling the conflict.

  • REPLACE existing work with the new work. This option cancels the existing work.
  • KEEP existing work and ignore the new work.
  • APPEND the new work to the end of the existing work. This policy will cause your new work to be chained to the existing work, running after the existing work finishes.

The existing work becomes a prerequisite to the new work. If the existing work becomes CANCELLED or FAILED, the new work is also CANCELLED or FAILED. If you want the new work to run regardless of the status of the existing work, use APPEND_OR_REPLACE instead.

  • APPEND_OR_REPLACE functions similarly to APPEND, except that it is not dependent on prerequisite work status. If the existing work is CANCELLED or FAILED, the new work still runs.

For period work, you provide an ExistingPeriodicWorkPolicy, which supports 2 options, REPLACE and KEEP. These options function the same as their ExistingWorkPolicy counterparts.

Unique work sequences can also be useful if you need to gradually build up a long chain of tasks. For example, a photo editing app might let users undo a long chain of actions. Each of those undo operations might take a while, but they have to be performed in the correct order. In this case, the app could create an “undo” chain and append each undo operation to the chain as needed.

Observing your work

At any point after enqueuing work, you can check its status by querying WorkManager by its name, id or by a tag associated with it.

// by id
workManager.getWorkInfoById(syncWorker.id) // ListenableFuture<WorkInfo>
// by name
workManager.getWorkInfosForUniqueWork("sync") // ListenableFuture<List<WorkInfo>>
// by tag
workManager.getWorkInfosByTag("syncTag") // ListenableFuture<List<WorkInfo>>
workManager.getWorkInfoByIdLiveData(syncWorker.id)
.observe(viewLifecycleOwner) { workInfo ->
if(workInfo?.state == WorkInfo.State.SUCCEEDED) {
Snackbar.make(requireView(),
R.string.work_completed, Snackbar.LENGTH_SHORT)
.show()
}
}

Complex Queries:

The following example shows how you can find all work with the tag, “syncTag”, that is in the FAILED or CANCELLED state and has a unique work name of either “preProcess” or “sync”.

val workQuery = WorkQuery.Builder
.fromTags(listOf("syncTag"))
.addStates(listOf(WorkInfo.State.FAILED, WorkInfo.State.CANCELLED))
.addUniqueWorkNames(listOf("preProcess", "sync")
)
.build()
val workInfos: ListenableFuture<List<WorkInfo>> = workManager.getWorkInfos(workQuery)

Cancel Work:

// by id
workManager.cancelWorkById(syncWorker.id)
// by name
workManager.cancelUniqueWork("sync")
// by tag
workManager.cancelAllWorkByTag("syncTag")

Any WorkRequest jobs that are dependent on this work will also be CANCELLED.

Currently RUNNING work receives a call to ListenableWorker.onStopped(). Override this method to handle any potential cleanup.

Note: cancelAllWorkByTag(String) cancels all work with the given tag.

Stopping Work:

WorkManager invokes ListenableWorker.onStopped() as soon as your Worker has been stopped. Override this method to close any resources you may be holding onto.

You can call the ListenableWorker.isStopped() method to check if your worker has already been stopped.

There are a few different reasons your running Worker might be stopped by WorkManager:

  • You explicitly asked for it to be cancelled (by calling WorkManager.cancelWorkById(UUID), for example).
  • In the case of unique work, you explicitly enqueued a new WorkRequest with an ExistingWorkPolicy of REPLACE. The old WorkRequest is immediately considered cancelled.
  • Your work’s constraints are no longer met.
  • The system instructed your app to stop your work for some reason. This can happen if you exceed the execution deadline of 10 minutes. The work is scheduled for retry at a later time.

Under these conditions, your Worker is stopped.

Updating Progress

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
import kotlinx.coroutines.delay
class ProgressWorker(context: Context, parameters: WorkerParameters) :
CoroutineWorker(context, parameters) {
companion object {
const val Progress = "Progress"
private const val delayDuration = 1L
}
override suspend fun doWork(): Result {
val firstUpdate = workDataOf(Progress to 0)
val lastUpdate = workDataOf(Progress to 100)
setProgress(firstUpdate)
delay(delayDuration)
setProgress(lastUpdate)
return Result.success()
}
}
WorkManager.getInstance(applicationContext)
// requestId is the WorkRequest id
.getWorkInfoByIdLiveData(requestId)
.observe(observer, Observer { workInfo: WorkInfo? ->
if (workInfo != null) {
val progress = workInfo.progress
val value = progress.getInt(Progress, 0)
// Do something with progress information
}
})

Chaining Work:

To create a chain of work, you can use WorkManager.beginWith(OneTimeWorkRequest) or WorkManager.beginWith(List<OneTimeWorkRequest>) , which return an instance of WorkContinuation.

A WorkContinuation can then be used to add dependent OneTimeWorkRequests using WorkContinuation.then(OneTimeWorkRequest) or WorkContinuation.then(List<OneTimeWorkRequest>) .

WorkManager.getInstance(myContext)
// Candidates to run in parallel
.beginWith(listOf(filter1, filter2, filter3))
// Dependent work (only runs after all previous work in chain)
.then(compress)
.then(upload)
// Don't forget to enqueue()
.enqueue()

Input Mergers

When using chains of OneTimeWorkRequests, the output of parent OneTimeWorkRequests are passed in as inputs to the children. So in the above example, the outputs of filter1, filter2 and filter3 would be passed in as inputs to the compress request.

In order to manage inputs from multiple parent OneTimeWorkRequests, WorkManager uses InputMergers.

There are two different types of InputMergers provided by WorkManager:

For the above example, given we want to preserve the outputs from all image filters, we should use an ArrayCreatingInputMerger.

val compress: OneTimeWorkRequest = OneTimeWorkRequestBuilder<CompressWorker>()
.setInputMerger(ArrayCreatingInputMerger::class)
.setConstraints(constraints)
.build()
// Define the parameter keys:
const val KEY_X_ARG = "X"
const val KEY_Y_ARG = "Y"
const val KEY_Z_ARG = "Z"
// ...and the result key:
const val KEY_RESULT = "result"
// Define the Worker class:
class MathWorker(context : Context, params : WorkerParameters)
: Worker(context, params) {
override fun doWork(): Result {
val x = inputData.getInt(KEY_X_ARG, 0)
val y = inputData.getInt(KEY_Y_ARG, 0)
val z = inputData.getInt(KEY_Z_ARG, 0)
// ...do the math...
val result = myLongCalculation(x, y, z);
//...set the output, and we're done!
val output: Data = workDataOf(KEY_RESULT to result)
return Result.success(output)
}
}
val myData: Data = workDataOf("KEY_X_ARG" to 42,
"KEY_Y_ARG" to 421,
"KEY_Z_ARG" to 8675309)
// ...then create and enqueue a OneTimeWorkRequest that uses those arguments
val mathWork = OneTimeWorkRequestBuilder<MathWorker>()
.setInputData(myData)
.build()
WorkManager.getInstance(myContext).enqueue(mathWork)
WorkManager.getInstance(myContext).getWorkInfoByIdLiveData(mathWork.id)
.observe(this, Observer { info ->
if (info != null && info.state.isFinished) {
val myResult = info.outputData.getInt(KEY_RESULT,
myDefaultValue)
// ... do something with the result ...
}
})

Chaining and Work Statuses

There are a couple of things to keep in mind when creating chains of OneTimeWorkRequests.

  • Dependent OneTimeWorkRequests are only unblocked (transition to ENQUEUED), when all its parent OneTimeWorkRequests are successful (that is, they return a Result.success()).
  • When any parent OneTimeWorkRequest fails (returns a Result.failure(), then all dependent OneTimeWorkRequests are also marked as FAILED.
  • When any parent OneTimeWorkRequest is cancelled, all dependent OneTimeWorkRequests are also marked as CANCELLED.

You can create more complex sequences by joining multiple chains with the WorkContinuation.combine(List<OneTimeWorkRequest>) methods. For example, suppose you want to run a sequence like this:

val chain1 = WorkManager.getInstance(myContext)
.beginWith(workA)
.then(workB)
val chain2 = WorkManager.getInstance(myContext)
.beginWith(workC)
.then(workD)
val chain3 = WorkContinuation
.combine(Arrays.asList(chain1, chain2))
.then(workE)
chain3.enqueue()

Every chain runs on their executor Service and once combine is called then it awaits for executor termination which completes all threads for chain1 and chain2 and then it run workE.

How to wait for Executor service to complete:

https://www.baeldung.com/java-executor-wait-for-threads

Important Note: PeriodicWork can’t be chained to save CPU resources, only ONE TIME WORK supports chaining .

Custom Workmanager:Why?

1.) It requires once you want to customize Default implementation like ThreadPool Executor implementation.By default, WorkManager sets up an Executor for you - but you can also customize your own. For example, you can share an existing background Executor in your app, or create a single-threaded Executor to make sure all your background work executes serially, or even specify a ThreadPool with a different thread count. To customize the Executor, make sure you have enabled manual initialization of WorkManager.

2.) On-demand initialization lets you create WorkManager only when that component is needed, instead of every time the app starts up. Doing so moves WorkManager off your critical startup path, improving app startup performance.

To provide your own configuration, you must first remove the default initializer. To do so, update AndroidManifest.xml using the merge rule tools:node="remove":

<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
tools:node="remove" />
class MyApplication() : Application(), Configuration.Provider {
override fun getWorkManagerConfiguration() =
Configuration.Builder()
.setExecutor(Executors.newFixedThreadPool(8))
.build()
}
// initialize WorkManager
WorkManager.initialize(context, myConfig)

When you need to use WorkManager, make sure to call the method WorkManager.getInstance(Context). WorkManager calls your app's custom getWorkManagerConfiguration() method to discover its Configuration.

Make sure the initialization runs either in Application.onCreate() or in a ContentProvider.onCreate().

Running Workmanager in Coroutines:

Note that CoroutineWorker.doWork() is a suspending function. Unlike Worker, this code does not run on the Executor specified in your Configuration. Instead, it defaults to Dispatchers.Default. You can customize this by providing your own CoroutineContext. In the below example, you would probably want to do this work on Dispatchers.IO, as follows:

class CoroutineDownloadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {    override val coroutineContext = Dispatchers.IO    override suspend fun doWork(): Result = coroutineScope {
val jobs = (0 until 100).map {
async {
downloadSynchronously("https://www.google.com")
}
}
// awaitAll will throw an exception if a download fails, which CoroutineWorker will treat as a failure
jobs.awaitAll()
Result.success()
}
}

CoroutineWorkers handle stoppages automatically by cancelling the coroutine and propagating the cancellation signals. You don't need to do anything special to handle work stoppages.

Running WorkManager with own Thread Strategy:

you may need to handle a callback-based asynchronous operation. In this case, you cannot simply rely on a Worker because it can't do the work in a blocking fashion. WorkManager supports this use case with ListenableWorker

What happens if your work is stopped? A ListenableWorker's ListenableFuture is always cancelled when the work is expected to stop. Using a CallbackToFutureAdapter, you simply have to add a cancellation listener, as follows:

public class CallbackWorker extends ListenableWorker {    public CallbackWorker(Context context, WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public ListenableFuture<Result> startWork() {
return CallbackToFutureAdapter.getFuture(completer -> {
Callback callback = new Callback() {
int successes = 0;
@Override
public void onFailure(Call call, IOException e) {
completer.setException(e);
}
@Override
public void onResponse(Call call, Response response) {
++successes;
if (successes == 100) {
completer.set(Result.success());
}
}
};
completer.addCancellationListener(cancelDownloadsRunnable, executor); for (int i = 0; i < 100; ++i) {
downloadAsynchronously("https://www.google.com", callback);
}
return callback;
});
}
}

Long Running Workers:

WorkManager can provide a signal to the OS that the process should be kept alive if possible while this work is executing. These Workers can run longer than 10 minutes. Example use-cases for this new feature include bulk uploads or downloads (that cannot be chunked), crunching on an ML model locally, or a task that’s important to the user of the app.

Under the hood, WorkManager manages and runs a foreground service on your behalf to execute the WorkRequest, while also showing a configurable notification.

class DownloadWorker(context: Context, parameters: WorkerParameters) :
CoroutineWorker(context, parameters) {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as
NotificationManager
override suspend fun doWork(): Result {
val inputUrl = inputData.getString(KEY_INPUT_URL)
?: return Result.failure()
val outputFile = inputData.getString(KEY_OUTPUT_FILE_NAME)
?: return Result.failure()
// Mark the Worker as important
val progress = "Starting Download"
setForeground(createForegroundInfo(progress))
download(inputUrl, outputFile)
return Result.success()
}
private fun download(inputUrl: String, outputFile: String) {
// Downloads a file and updates bytes read
// Calls setForegroundInfo() periodically when it needs to update
// the ongoing Notification
}
// Creates an instance of ForegroundInfo which can be used to update the
// ongoing notification.
private fun createForegroundInfo(progress: String): ForegroundInfo {
val id = applicationContext.getString(R.string.notification_channel_id)
val title = applicationContext.getString(R.string.notification_title)
val cancel = applicationContext.getString(R.string.cancel_download)
// This PendingIntent can be used to cancel the worker
val intent = WorkManager.getInstance(applicationContext)
.createCancelPendingIntent(getId())
// Create a Notification channel if necessary
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createChannel()
}
val notification = NotificationCompat.Builder(applicationContext, id)
.setContentTitle(title)
.setTicker(title)
.setContentText(progress)
.setSmallIcon(R.drawable.ic_work_notification)
.setOngoing(true)
// Add the cancel action to the notification which can
// be used to cancel the worker
.addAction(android.R.drawable.ic_delete, cancel, intent)
.build()
return ForegroundInfo(notification)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createChannel() {
// Create a Notification channel
}
companion object {
const val KEY_INPUT_URL = "KEY_INPUT_URL"
const val KEY_OUTPUT_FILE_NAME = "KEY_OUTPUT_FILE_NAME"
}
}

If Long Running Worker uses Foreground service it is said to be background task if started from background worker without activity interaction.

If you want to access Location, Camera and Audio in the Foreground Service , you have to define them in manifest for accessing these services and Once user gives permission for these services only then these services can be used. for more detail read blog:

Foreground Service:

https://developer.android.com/preview/privacy/foreground-services

<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="location"
tools:node="merge" />
// Updates the example worker to specify a foreground service type of
// "location".
private fun createForegroundInfo(progress: String): ForegroundInfo {
// ...
// You don't need to make any changes to the notification object.
return ForegroundInfo(NOTIFICATION_ID, notification,
FOREGROUND_SERVICE_TYPE_LOCATION)
}

WorkManager Testing:

Testing of WorkManager does not need any special handling , it is very similar to the work you do for real application.

Only 2 classes(WorkManagerTestInitHelper, TestDriver) need for some setup and all other things are similar to real app. Please below link:

val testDriver = WorkManagerTestInitHelper.getTestDriver()

https://developer.android.com/topic/libraries/architecture/workmanager/how-to/integration-testing

I hope , This article will give the overall quick view about WorkManager functioning where it should be used and how it can be customised. Please do clap if you like it :). Thanks.

--

--