Read and copy file with Coroutines

Viewed 3089

I created the following application to illustrate some doubts. My Example on the Github

In this example, I copy a file to another package.

My doubts are as follows:

  1. Performing the tasks in parallel, is it possible to return the values that were completed before the cancellation?

  2. Why in contentResolver.openInputStream (uri) the message "Inappropriate blocking method call" appears, while I am working with IO context?

  3. While I am reading the file entry to copy to output, I always check the job status so that when this task is canceled, it is stopped immediately, the output file that was created is deleted and returns the cancellation exception, is that correct?

  4. Can I delimit the amount of tasks that are performed?

My onCreate:

private val listUri = mutableListOf<Uri>()
private val job = Job()

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    
    //get files from 1 to 40
    val packageName = "android.resource://${packageName}/raw/"
    for (i in 1..40) {
        listUri.add(Uri.parse("${packageName}file$i"))
    }
}

My button action:

  //Button action
   fun onClickStartTask(view: View) {
        var listNewPath = emptyList<String>()
        CoroutineScope(Main + job).launch {
            try {
                //shows something in the UI - progressBar
                withContext(IO) {
                    listNewPath = listUri.map { uri ->
                        async {
                            //path to file temp
                            val pathFileTemp =
                                "${getExternalFilesDir("Temp").toString()}/${uri.lastPathSegment}"
                            val file = File(pathFileTemp)
                            val inputStream = contentResolver.openInputStream(uri)
                            inputStream?.use { input ->
                                FileOutputStream(file).use { output ->
                                    val buffer = ByteArray(1024)
                                    var read: Int = input.read(buffer)
                                    while (read != -1) {
                                        if (isActive) {
                                            output.write(buffer, 0, read)
                                            read = input.read(buffer)
                                        } else {
                                            input.close()
                                            output.close()
                                            file.deleteRecursively()
                                            throw CancellationException()
                                        }
                                    }
                                }
                            }
                            //If completed then it returns the new path.
                            return@async pathFileTemp
                        }
                    }.awaitAll()
                }
            } finally {
                //shows list complete in the UI
            }
        }
    }

My button to cancel job:

fun onClickCancelTask(view: View) {
    if (job.isActive) {
        job.cancelChildren()
        println("Cancel children")
    }
}

This would be the button action to perform the task.

I thank all the help.

2 Answers

Answering 1. and 4.:

In order to delimit parallel tasks and let them finish independently (getting some values, while cancelling the rest) you would need to use a Channel and preferably a Flow. Simplified example:

fun processListWithSomeWorkers(list: List<Whatever>, concurrency: Int): Flow<Result> = channelFlow {
   val workToDistribute = Channel<Whatever>()
   launch { for(item in list) workToDistribute.send(item) } // one coroutine distributes work...

    repeat(concurrency) { // launch a specified number of worker coroutines
      launch { 
         for (task in workToDistribute) { // which process tasks in a loop
            val atomicResult = process(task)
            send(atomicResult) // and send results downstream to a Flow
         }
      }
   }
}

And then you can process results, one by one, as they are getting produced waiting for the whole flow to finish or e.g. just take some of them, when desired: resultFlow.take(20).onEach { ... }.collectIn(someScope) Because it is a Flow, it will start working only when somebody starts collecting (it is cold), which is usually a good thing.

Whole thing may be made a little shorter, as you will discover some more specific and experimental functions (as produce). It can be generalized as a Flow operator like this:

fun <T, R> Flow<T>.concurrentMap(concurrency: Int, transform: suspend (T) -> R): Flow<R> {
    require(concurrency > 1) { "No sense with concurrency < 2" }
    return channelFlow {
        val inputChannel = produceIn(this)
        repeat(concurrency) {
            launch {
                for (input in inputChannel) send(transform(input))
            }
        }
    }
}

and used: list.asFlow().concurrentMap(concurrency = 4) { <your mapping logic> }

The corotuines team are thinking about adding a family of parallel operators to the Flow streams, but they are not there yet AFAIK.

I think this is a better way

fun onClickStartTask(view: View) {
    var listNewPath = emptyList<String>()
    val copiedFiles = mutableListOf<File>()
    CoroutineScope(Dispatchers.Main + job).launch {
        try {
            //shows something in the UI - progressBar
            withContext(Dispatchers.IO) {
                listNewPath = listUri.map { uri ->
                    async {
                        //path to file temp
                        val pathFileTemp =
                                "${getExternalFilesDir("Temp").toString()}/${uri.lastPathSegment}"
                        val file = File(pathFileTemp)
                        val inputStream = contentResolver.openInputStream(uri)
                        inputStream?.use { input ->
                            file.outputStream().use { output ->
                                copiedFiles.add(file)
                                input.copyTo(output, 1024)
                            }
                        }

                        //If completed then it returns the new path.
                        return@async pathFileTemp
                    }
                }.awaitAll()
            }
        } finally {
            //shows list complete in the UI
        }
    }
    job.invokeOnCompletion {
        it?.takeIf { it is CancellationException }?.let {
            GlobalScope.launch(Dispatchers.IO) {
                copiedFiles.forEach { file ->
                    file.delete()
                }
            }
        }
    }
}
Related