Hey Android Devs, in this article we will go through a step by step example of implementing a download feature in your apps using WorkManager.
We will be using Jetpack Compose for UI instead of traditional XML layout files.
So, let’s get started with the first step of adding Work Manager to our project.
Step 1: Add workManager dependency to the project
implementation “androidx.work:work-runtime-ktx:2.7.1”
Step 2: Define a File Download Worker
Create a new class called FileDownloadWorker and extend it from CoroutineWorker
class FileDownloadWorker( | |
private val context:Context, | |
workerParameters: WorkerParameters | |
): CoroutineWorker(context, workerParameters) { | |
override suspend fun doWork(): Result { | |
} | |
} |
Now define two constants objects one for input and output data and other for notifications.
object FileParams{ | |
const val KEY_FILE_URL = "key_file_url" | |
const val KEY_FILE_TYPE = "key_file_type" | |
const val KEY_FILE_NAME = "key_file_name" | |
const val KEY_FILE_URI = "key_file_uri" | |
} | |
object NotificationConstants{ | |
const val CHANNEL_NAME = "download_file_worker_demo_channel" | |
const val CHANNEL_DESCRIPTION = "download_file_worker_demo_description" | |
const val CHANNEL_ID = "download_file_worker_demo_channel_123456" | |
const val NOTIFICATION_ID = 1 | |
} |
After this, define a function to save files to the Downloads folder from the URL.
Now due to changes in storage APIs in Android 11 onwards, we will for all the SDKs.
private fun getSavedFileUri( | |
fileName:String, | |
fileType:String, | |
fileUrl:String, | |
context: Context): Uri?{ | |
val mimeType = when(fileType){ | |
"PDF" -> "application/pdf" | |
"PNG" -> "image/png" | |
"MP4" -> "video/mp4" | |
else -> "" | |
} // different types of files will have different mime type | |
if (mimeType.isEmpty()) return null | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q){ | |
val contentValues = ContentValues().apply { | |
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) | |
put(MediaStore.MediaColumns.MIME_TYPE, mimeType) | |
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/DownloaderDemo") | |
} | |
val resolver = context.contentResolver | |
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) | |
return if (uri!=null){ | |
URL(fileUrl).openStream().use { input-> | |
resolver.openOutputStream(uri).use { output-> | |
input.copyTo(output!!, DEFAULT_BUFFER_SIZE) | |
} | |
} | |
uri | |
}else{ | |
null | |
} | |
}else{ | |
val target = File( | |
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), | |
fileName | |
) | |
URL(fileUrl).openStream().use { input-> | |
FileOutputStream(target).use { output -> | |
input.copyTo(output) | |
} | |
} | |
return target.toUri() | |
} | |
} |
Here we have restricted mimeType to Pdf, Png and Mp4 only. You can add others as well. We are using MediaStore for SDK ≥ 29 and ExternalStoragePublicDirectory for the rest. The getSavedFileUri() function will return a URI if the file has been saved successfully, otherwise, it will return null.
Now we need to show notification while saving a file. For displaying notification, we need to add NotificationChannels for Android Oreo onwards.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){ | |
val name = NotificationConstants.CHANNEL_NAME | |
val description = NotificationConstants.CHANNEL_DESCRIPTION | |
val importance = NotificationManager.IMPORTANCE_HIGH | |
val channel = NotificationChannel(NotificationConstants.CHANNEL_ID,name,importance) | |
channel.description = description | |
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? | |
notificationManager?.createNotificationChannel(channel) | |
} | |
val builder = NotificationCompat.Builder(context,NotificationConstants.CHANNEL_ID) | |
.setSmallIcon(R.drawable.ic_launcher_foreground) | |
.setContentTitle("Downloading your file...") | |
.setOngoing(true) | |
.setProgress(0,0,true) | |
NotificationManagerCompat.from(context).notify(NotificationConstants.NOTIFICATION_ID,builder.build()) |
We only need to show notification while saving of file is in progress. After that we have to cancel it as well.
Now the doWork() method will something look like this.
override suspend fun doWork(): Result { | |
val fileUrl = inputData.getString(FileParams.KEY_FILE_URL) ?: "" | |
val fileName = inputData.getString(FileParams.KEY_FILE_NAME) ?: "" | |
val fileType = inputData.getString(FileParams.KEY_FILE_TYPE) ?: "" | |
Log.d("TAG", "doWork: $fileUrl | $fileName | $fileType") | |
if (fileName.isEmpty() | |
|| fileType.isEmpty() | |
|| fileUrl.isEmpty() | |
){ | |
Result.failure() | |
} | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){ | |
val name = NotificationConstants.CHANNEL_NAME | |
val description = NotificationConstants.CHANNEL_DESCRIPTION | |
val importance = NotificationManager.IMPORTANCE_HIGH | |
val channel = NotificationChannel(NotificationConstants.CHANNEL_ID,name,importance) | |
channel.description = description | |
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? | |
notificationManager?.createNotificationChannel(channel) | |
} | |
val builder = NotificationCompat.Builder(context,NotificationConstants.CHANNEL_ID) | |
.setSmallIcon(R.drawable.ic_launcher_foreground) | |
.setContentTitle("Downloading your file...") | |
.setOngoing(true) | |
.setProgress(0,0,true) | |
NotificationManagerCompat.from(context).notify(NotificationConstants.NOTIFICATION_ID,builder.build()) | |
val uri = getSavedFileUri( | |
fileName = fileName, | |
fileType = fileType, | |
fileUrl = fileUrl, | |
context = context | |
) | |
NotificationManagerCompat.from(context).cancel(NotificationConstants.NOTIFICATION_ID) | |
return if (uri != null){ | |
Result.success(workDataOf(FileParams.KEY_FILE_URI to uri.toString())) | |
}else{ | |
Result.failure() | |
} | |
} |
Step 3: Define File to be downloaded Model
data class File( | |
val id:String, | |
val name:String, | |
val type:String, | |
val url:String, | |
var downloadedUri:String?=null, | |
var isDownloading:Boolean = false, | |
) |
We have added initialized downloadedUri with null as by default we are assuming the file is not downloaded yet and we don’t have its URI
The isDownloading will be used by the composable to know whether the file is downloading or not.
Step 4: Define the composable which will hold File data
@Composable | |
fun ItemFile( | |
file: File, | |
startDownload:(File) -> Unit, | |
openFile:(File) -> Unit | |
) { | |
Box( | |
contentAlignment = Alignment.Center, | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(color = Color.White) | |
.border(width = 2.dp, color = Color.Blue, shape = RoundedCornerShape(16.dp)) | |
.clickable { | |
if (!file.isDownloading){ | |
if (file.downloadedUri.isNullOrEmpty()){ | |
startDownload(file) | |
}else{ | |
openFile(file) | |
} | |
} | |
} | |
.padding(16.dp) | |
) { | |
Row( | |
modifier = Modifier | |
.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Column( | |
modifier = Modifier | |
.fillMaxWidth(0.8f) | |
) { | |
Text( | |
text = file.name, | |
style = Typography.body1, | |
color = Color.Black | |
) | |
Row { | |
val description = if (file.isDownloading){ | |
"Downloading..." | |
}else{ | |
if (file.downloadedUri.isNullOrEmpty()) "Tap to download the file" else "Tap to open file" | |
} | |
Text( | |
text = description, | |
style = Typography.body2, | |
color = Color.DarkGray | |
) | |
} | |
} | |
if (file.isDownloading){ | |
CircularProgressIndicator( | |
color = Color.Blue, | |
modifier = Modifier | |
.size(32.dp) | |
.align(Alignment.CenterVertically) | |
) | |
} | |
} | |
} | |
} |
Job Offers
ItemFile takes File as a parameter and has two lambda functions to start downloading and open a particular file.
The description depends on isDownloading and downloadedUri of File.
On click of the container we will start downloading if we don’t have downloaded file URI otherwise we will open the file using that URI.
Step 5: Add permissions to your Manifest File (Before you forget)
<uses-permission android:name="android.permission.INTERNET" /> | |
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> | |
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> |
Step 6: Take permission from user
We will use ActivityResultLauncher to take read and write because oviously onActivityResult is depreciated now.
class MainActivity : ComponentActivity() { | |
private lateinit var requestMultiplePermission: ActivityResultLauncher<Array<String>> | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
requestMultiplePermission = registerForActivityResult( | |
ActivityResultContracts.RequestMultiplePermissions() | |
){ | |
var isGranted = false | |
it.forEach { s, b -> | |
isGranted = b | |
} | |
if (!isGranted){ | |
Toast.makeText(this, "Permission Not Granted", Toast.LENGTH_SHORT).show() | |
} | |
} | |
setContent { | |
DownloadFileWorkManagerDemoTheme { | |
Surface( | |
modifier = Modifier.fillMaxSize(), | |
color = MaterialTheme.colors.background | |
) { | |
requestMultiplePermission.launch( | |
arrayOf( | |
Manifest.permission.READ_EXTERNAL_STORAGE, | |
Manifest.permission.WRITE_EXTERNAL_STORAGE | |
) | |
) | |
Home() | |
} | |
} | |
} | |
} |
Since we have permission as well, now we can start defining our work manager and implementing the file download function.
The startDownloadingFile() function will have one File object and three lambda functions to define the current state, i.e; success, failed and running.
This will be a one-time work request since we only need to download one file at a time. We will also add some constraints to this worker request which are necessary such as Internet should be available and storage or battery should not be low.
We will keep track of the current state of worker by the live data provided by workManger.
The complete function will look something like this.
private fun startDownloadingFile( | |
file: File, | |
success:(String) -> Unit, | |
failed:(String) -> Unit, | |
running:() -> Unit | |
) { | |
val data = Data.Builder() | |
data.apply { | |
putString(FileDownloadWorker.FileParams.KEY_FILE_NAME, file.name) | |
putString(FileDownloadWorker.FileParams.KEY_FILE_URL, file.url) | |
putString(FileDownloadWorker.FileParams.KEY_FILE_TYPE, file.type) | |
} | |
val constraints = Constraints.Builder() | |
.setRequiredNetworkType(NetworkType.CONNECTED) | |
.setRequiresStorageNotLow(true) | |
.setRequiresBatteryNotLow(true) | |
.build() | |
val fileDownloadWorker = OneTimeWorkRequestBuilder<FileDownloadWorker>() | |
.setConstraints(constraints) | |
.setInputData(data.build()) | |
.build() | |
workManager.enqueueUniqueWork( | |
"oneFileDownloadWork_${System.currentTimeMillis()}", | |
ExistingWorkPolicy.KEEP, | |
fileDownloadWorker | |
) | |
workManager.getWorkInfoByIdLiveData(fileDownloadWorker.id) | |
.observe(this){ info-> | |
info?.let { | |
when (it.state) { | |
WorkInfo.State.SUCCEEDED -> { | |
success(it.outputData.getString(FileDownloadWorker.FileParams.KEY_FILE_URI) ?: "") | |
} | |
WorkInfo.State.FAILED -> { | |
failed("Downloading failed!") | |
} | |
WorkInfo.State.RUNNING -> { | |
running() | |
} | |
else -> { | |
failed("Something went wrong") | |
} | |
} | |
} | |
} | |
} |
Step 7: Define a dummy File data and implement the rest
To define the data we will make use of mutableStateOf, so that our composable recomposes when the state of file changes.
When the state of workManager changes, we will make changes in the state of file data.
On Success, we will set isDownloading = false and set the downloadedURI to the ouput URI.
On failure, we will set isDownloading = false and downloadedURI = null.
When in running state, we will set isDownloading = true
Once the file is downloaded, on tap of it, the downloaded file will be opened through intent.
@Composable | |
fun Home() { | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(32.dp), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
val data = remember { | |
mutableStateOf( | |
File( | |
id = "10", | |
name = "Pdf File 10 MB", | |
type = "PDF", | |
url = "https://www.learningcontainer.com/wp-content/uploads/2019/09/sample-pdf-download-10-mb.pdf", | |
downloadedUri = null | |
) | |
) | |
} | |
ItemFile( | |
file = data.value, | |
startDownload = { | |
startDownloadingFile( | |
file = data.value, | |
success = { | |
data.value = data.value.copy().apply { | |
isDownloading = false | |
downloadedUri = it | |
} | |
}, | |
failed = { | |
data.value = data.value.copy().apply { | |
isDownloading = false | |
downloadedUri = null | |
} | |
}, | |
running = { | |
data.value = data.value.copy().apply { | |
isDownloading = true | |
} | |
} | |
) | |
}, | |
openFile = { | |
val intent = Intent(Intent.ACTION_VIEW) | |
intent.setDataAndType(it.downloadedUri?.toUri(),"application/pdf") | |
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) | |
try { | |
startActivity(intent) | |
}catch (e:ActivityNotFoundException){ | |
Toast.makeText( | |
this@MainActivity, | |
"Can't open Pdf", | |
Toast.LENGTH_SHORT | |
).show() | |
} | |
} | |
) | |
} | |
} |
When you run the app, you will be asked for permissions, after that once you start downloding it, you can see the progress bar with a notification as well. Once downloaded, you tap to open the downloaded file.
That’s it for this example. See you in the next one.
You can connect with me on LinkedIn and Twitter.
This article was originally published on proandroiddev.com on March 04, 2022