Efficient Background File Uploads with WorkManager: A Step-by-Step Guide
Introduction
In today's mobile landscape, asynchronous background tasks play a crucial role in providing a seamless user experience. Whether it's syncing data, fetching updates, or uploading files, handling these tasks efficiently is essential for the success of any mobile app. In this blog post, we'll explore how to leverage WorkManager, an essential component of the Android Jetpack library, to implement a robust background file uploader in your Android app.
Setup
In order to get started with Work Manager, we need to add following dependencies to the app level build.gradle
file.
val work_version = "2.9.0"
implementation("androidx.work:work-runtime-ktx:$work_version")
After adding the dependencies, synchronize the project and let's dive to do some work.
Creating File Upload Worker
Before we continue, it's important to ensure that you understand how WorkManager schedules tasks. If not, you can review the process through this link.
To utilize WorkManager effectively, the initial step is to create a worker. There are two types of workers based on the nature of the task:
Worker
: It runs synchronously on a background thread managed by WorkManager. It is suitable for tasks that are not inherently asynchronous or require managing their own threading.CoroutineWorker
: It is an extension ofWorker
that supports coroutines for asynchronous operations.
In the provided code snippet, we've established the FileUploadWorker class, which inherits from CoroutineWorker to manage file uploads. It takes the application context and worker parameters as inputs. Inside the doWork() function, we trigger the file upload process by calling the uploadFile() method and subsequently return a success state. However, in real-world scenarios, we encounter error conditions and require progress indication.
class FileUploadWorker(private val appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
uploadFile()
Result.success()
}
private suspend fun uploadFile():Unit
}
Implementing File Upload
In our worker class, we define the uploadFile()
function to handle the upload process. To simplify, we'll create a mock upload function with the following structure:
private suspend fun uploadFile(uri: String?) {
// Check if the file URI is provided
if (uri == null)
throw UploadingFailedException.FileNotFound
// Simulate random failure for demonstration
if (Random.nextInt() % 2 == 0)
throw UploadingFailedException.Failed
// Update notification to indicate file upload initiation
sendNotification("Uploading File")
// Simulate upload progress
delay(4000L)
for (i in 0..100) {
delay(500)
sendNotification("Uploading File in progress: $i%")
}
// Simulate delay for completion
delay(5000L)
// Update notification to indicate successful upload
sendNotification("Uploaded Successfully")
}
This function simulates the file upload process, handling various stages such as initiation, progress tracking, and completion.
In the mentioned function, we verify the existence of the file to be uploaded by checking its URI. Additionally, for demonstration purposes, an additional error case is included to showcase exception handling during the upload process. Assuming everything proceeds as expected, the file upload is initiated, and the notification is updated accordingly.
Note that the details of handling and sending notifications are not covered in this blog, but the implementation of the NotificationHelper
class can be found in the provided source code.
Furthermore, two custom exceptions have been created to address specific error scenarios, facilitating effective management of work states.
sealed class UploadingFailedException(override val message: String) : Exception(message) {
object FileNotFound : UploadingFailedException(message = "File not found! try again.")
object Failed :
UploadingFailedException(message = "Upload File Failed! will try again in 10 seconds")
}
Modifying our File Upload Worker
After we have implementing our fileUpload()
function, the next step is to modify our worker by implementing doWork()
to handle the states and emit results accordingly.
override suspend fun doWork(): Result = runCatching {
val uri = inputData.getString(FILE_URI_TO_UPLOAD) // 1
uploadFile(uri) // 2
Result.success() // 3
}.getOrElse {
return@getOrElse when (it) { // 4
is UploadingFailedException.Failed -> {
sendNotification("Error: ${it.message}")
Result.retry() // 5
}
is UploadingFailedException.FileNotFound -> {
sendNotification("Error: ${it.message}")
Result.failure() // 6
}
else -> {
sendNotification("Upload Failed! Unknown Error")
Result.failure()
}
}
}
Now let's discuss the above mentioned points one-by-one.
WorkManager also offers us a way to get input data which may be required to perform the required operation. In our case, we are getting the uri of the file to be uploaded. How to send this input data will be discussed in the next section where we will be creating request*. Also check out*here
That's our file uploading function which we implemented.
In case of everything goes well we emit success result.
In case of some exceptions, we are checking them and handling accordingly.
If uploading is failed due to some error, we send
Result.retry()
It will tell the worker to retry after specified interval in the work request we built.If the file is not found occurs, we send
Result.failure()
which means that the uploading failed and will not be retried.
Complete Worker Implementation
package com.app.workmanagerbasics
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlinx.coroutines.delay
import kotlin.random.Random
class FileUploadWorker(private val appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result = runCatching {
val uri = inputData.getString(FILE_URI_TO_UPLOAD)
uploadFile(uri)
Result.success()
}.getOrElse {
return@getOrElse when (it) {
is UploadingFailedException.Failed -> {
sendNotification("Error: ${it.message}")
Result.retry()
}
is UploadingFailedException.FileNotFound -> {
sendNotification("Error: ${it.message}")
Result.failure()
}
else -> {
sendNotification("Upload Failed! Unknown Error")
Result.failure()
}
}
}
private suspend fun uploadFile(uri: String?) {
if (uri == null) throw UploadingFailedException.FileNotFound
if (Random.nextInt() % 2 == 0) throw UploadingFailedException.Failed
sendNotification("Uploading File")
delay(4000L)
for (i in 0..100) {
delay(500)
sendNotification("Uploading File in progress: $i%")
}
delay(5000L)
sendNotification("Uploaded Successfully")
}
private fun sendNotification(message: String) {
NotificationHelper(context = appContext).createNotification(
title = "File Uploader",
message = message
)
}
companion object {
const val FILE_URI_TO_UPLOAD = "imageUriToUpload"
}
}
sealed class UploadingFailedException(override val message: String) : Exception(message) {
object FileNotFound : UploadingFailedException(message = "File not found! try again.")
object Failed :
UploadingFailedException(message = "Upload File Failed! will try again in 10 seconds")
}
Building File Upload Work Request
Once our worker is implemented, the subsequent step involves scheduling the work through work requests. Given the choice between one-time and periodic work requests, we'll opt for a one-time work request with the expedited flag set to true.
Let's start building our request:
val uploadWorkRequest =
OneTimeWorkRequestBuilder<FileUploadWorker>()
.setId(UUID.randomUUID())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setInputData(
workDataOf(FILE_URI_TO_UPLOAD to "data/example.csv")
)
.build()
Here's the foundational implementation for a work request using the builder approach. In this request, we're assigning it a random ID, which can later be used for cancellation. It's set to expedited since the task isn't time-consuming and should be completed promptly.
Next, we utilize the setInputData()
function to provide the necessary data for our worker to carry out the task.
The next step is to schedule it using WorkManager like follows:
private val workManager = WorkManager.getInstance(this)
workManager.enqueue(uploadWorkRequest)
After calling enqueue()
we have scheduled the work and it will start immediately.
Setup Backoff Mechanism
Occasionally, exceptions may arise, prompting us to reschedule or cancel the ongoing task. To enable this functionality, we must define it in the work request builder.
This will only work in the case of Result.retry()
In our example, we have it in the case some unknown exception occurs.
val uploadWorkRequest =
OneTimeWorkRequestBuilder<FileUploadWorker>()
.setId(UUID.randomUUID())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setBackoffCriteria(
BackoffPolicy.LINEAR,
10,
TimeUnit.SECONDS
)
.setInputData(workDataOf(FILE_URI_TO_UPLOAD to "data/example.csv"))
.build()
We've configured a BackoffPolicy.Linear
with a minimum backoff of 10 seconds, which means that the retry interval will increment by 10 seconds with each subsequent attempt. You can find further information about backoff policies in the provided link and select the one that suits your needs.
Deferring Work via Constraints
We also have a set if constraints to defer the work until the optimal conditions are met. The following constraints are available in WorkManager:
NetworkType | Constrains the type of network required for your work to run. For example, Wi-Fi (UNMETERED ). |
BatteryNotLow | When set to true, your work will not run if the device is in low battery mode. |
RequiresCharging | When set to true, your work will only run when the device is charging. |
DeviceIdle | When set to true, this requires the user’s device to be idle before the work will run. This can be useful for running batched operations that might otherwise have a negative performance impact on other apps running actively on the user’s device. |
StorageNotLow | When set to true, your work will not run if the user’s storage space on the device is too low. |
To establish a set of constraints and link it to a task, instantiate a Constraints instance using Constraints.Builder()
and then assign it to your WorkRequest.Builder()
.
For instance, the code below constructs a work request that executes only when the user's device is both charging and connected to Wi-Fi:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresCharging(true)
.build()
val uploadWorkRequest =
OneTimeWorkRequestBuilder<FileUploadWorker>()
.setId(UUID.randomUUID())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setBackoffCriteria(
BackoffPolicy.LINEAR,
10,
TimeUnit.SECONDS
)
.setInputData(workDataOf(FILE_URI_TO_UPLOAD to "data/example.csv"))
.setConstraints(constraints)
.build()
When multiple constraints are specified, your work will run only when all the constraints are met.
In the event that a constraint becomes unmet while your work is running, WorkManager will stop your worker. The work will then be retried when all the constraints are met.
Cancelling the Work
WorkManager provides the capability to cancel work requests. Each work request possesses a unique identifier, which can be either an ID or a tag. While we've included the identifier in the previously implemented request, we can alternatively use addTag()
in the builder to designate the tag.
Tags are particularly useful when we're dealing with a group of related tasks. By tagging each of these requests, we gain the ability to manage all of them collectively.
In order to cancel the work we have the following methods, WorkManager.cancelAllWorkByTag(String)
and WorkManager.cancelAllWorkById(UUID)
workManager.cancelWorkById(id)
workManager.cancelAllWorkByTag(tag)
You can also integrate work cancellation directly into notifications by including a cancel pending intent in the action when sending the notification, as shown below:
// This PendingIntent can be used to cancel the worker
val intent = WorkManager.getInstance(applicationContext)
.createCancelPendingIntent(getId())
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()
Summary
In this comprehensive guide, we've demystified the process of implementing efficient background file uploads using WorkManager. From creating custom workers to defining work requests with constraints, we've covered the essential steps to streamline the upload process. By understanding result types and leveraging WorkManager's capabilities, developers can ensure smooth and reliable file uploads, enhancing user experiences in Android applications.
Useful Resources Links
Subscribe to my newsletter
Read articles from Asim Latif directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by