Improve file oprations coroutines

This commit is contained in:
Matéo Duparc 2023-05-09 23:13:46 +02:00
parent f85f9d1c44
commit 2ae41f0f79
Signed by: hardcoresushi
GPG Key ID: AFE384344A45E13A
11 changed files with 330 additions and 294 deletions

View File

@ -24,6 +24,7 @@ import sushi.hardcore.droidfs.databinding.ActivityMainBinding
import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerRouter
import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
@ -418,11 +419,11 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> VolumeData?) {
lifecycleScope.launch {
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
when {
result.taskResult.cancelled -> {
when (result.taskResult.state) {
TaskResult.State.CANCELLED -> {
result.dstRootDirectory?.delete()
}
result.taskResult.failedItem == null -> {
TaskResult.State.SUCCESS -> {
result.dstRootDirectory?.let {
getResultVolume(it)?.let { volume ->
volumeDatabase.saveVolume(volume)
@ -435,13 +436,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
}
}
else -> {
TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this@MainActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem.name))
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem!!.name))
.setPositiveButton(R.string.ok, null)
.show()
}
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
}
}
}

View File

@ -1,11 +1,16 @@
package sushi.hardcore.droidfs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
class VolumeManager {
private var id = 0
private val volumes = HashMap<Int, EncryptedVolume>()
private val volumesData = HashMap<VolumeData, Int>()
private val scopes = HashMap<Int, CoroutineScope>()
fun insert(volume: EncryptedVolume, data: VolumeData): Int {
volumes[id] = volume
@ -25,8 +30,13 @@ class VolumeManager {
return volumes[id]
}
fun getCoroutineScope(volumeId: Int): CoroutineScope {
return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it }
}
fun closeVolume(id: Int) {
volumes.remove(id)?.let { volume ->
scopes[id]?.cancel()
volume.close()
volumesData.filter { it.value == id }.forEach {
volumesData.remove(it.key)
@ -35,7 +45,10 @@ class VolumeManager {
}
fun closeAll() {
volumes.forEach { it.value.close() }
volumes.forEach {
scopes[it.key]?.cancel()
it.value.close()
}
volumes.clear()
volumesData.clear()
}

View File

@ -29,6 +29,7 @@ import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
import sushi.hardcore.droidfs.content_providers.ExternalProvider
import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.file_viewers.*
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
@ -42,7 +43,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
private var foldersFirst = true
private var mapFolders = true
private var currentSortOrderIndex = 0
private var volumeId = -1
protected var volumeId = -1
protected lateinit var encryptedVolume: EncryptedVolume
private lateinit var volumeName: String
private lateinit var explorerViewModel: ExplorerViewModel
@ -52,7 +53,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
explorerViewModel.currentDirectoryPath = value
}
protected lateinit var fileOperationService: FileOperationService
protected val taskScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
protected val activityScope = MainScope()
protected lateinit var explorerElements: MutableList<ExplorerElement>
protected lateinit var explorerAdapter: ExplorerElementAdapter
protected lateinit var app: VolumeManagerApp
@ -171,11 +172,8 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as FileOperationService.LocalBinder
fileOperationService = binder.getService()
binder.setEncryptedVolume(encryptedVolume)
}
override fun onServiceDisconnected(arg0: ComponentName) {
}
override fun onServiceDisconnected(arg0: ComponentName) {}
}, Context.BIND_AUTO_CREATE)
}
}
@ -439,7 +437,33 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
protected fun importFilesFromUris(uris: List<Uri>, callback: (String?) -> Unit) {
protected fun onTaskResult(
result: TaskResult<out String?>,
failedErrorMessage: Int,
successMessage: Int = -1,
onSuccess: (() -> Unit)? = null,
) {
when (result.state) {
TaskResult.State.SUCCESS -> {
if (onSuccess == null) {
Toast.makeText(this, successMessage, Toast.LENGTH_SHORT).show()
} else {
onSuccess()
}
}
TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(failedErrorMessage, result.failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
TaskResult.State.ERROR -> result.showErrorAlertDialog(this, theme)
TaskResult.State.CANCELLED -> {}
}
}
protected fun importFilesFromUris(uris: List<Uri>, callback: () -> Unit) {
val items = ArrayList<OperationFile>()
for (uri in uris) {
val fileName = PathUtils.getFilenameFromURI(this, uri)
@ -458,13 +482,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
if (items.size > 0) {
checkPathOverwrite(items, currentDirectoryPath) { checkedItems ->
checkedItems?.let {
taskScope.launch {
val taskResult = fileOperationService.importFilesFromUris(checkedItems.map { it.dstPath!! }, uris)
if (taskResult.cancelled) {
setCurrentPath(currentDirectoryPath)
} else {
callback(taskResult.failedItem)
}
activityScope.launch {
val result = fileOperationService.importFilesFromUris(volumeId, checkedItems.map { it.dstPath!! }, uris)
onTaskResult(result, R.string.import_failed, onSuccess = callback)
setCurrentPath(currentDirectoryPath)
}
}
}
@ -589,7 +610,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
override fun onDestroy() {
super.onDestroy()
if (!isChangingConfigurations) { //activity won't be recreated
taskScope.cancel()
activityScope.cancel()
}
}

View File

@ -17,9 +17,7 @@ import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.adapters.IconTextDialogAdapter
import sushi.hardcore.droidfs.content_providers.ExternalProvider
import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -36,7 +34,8 @@ class ExplorerActivity : BaseExplorerActivity() {
private val pickFromOtherVolumes = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let { resultIntent ->
val remoteEncryptedVolume = IntentUtils.getParcelableExtra<EncryptedVolume>(resultIntent, "volume")!!
val srcVolumeId = resultIntent.getIntExtra("volumeId", -1)
val srcEncryptedVolume = app.volumeManager.getVolume(srcVolumeId)!!
val path = resultIntent.getStringExtra("path")
val operationFiles = ArrayList<OperationFile>()
if (path == null){ //multiples elements
@ -48,7 +47,7 @@ class ExplorerActivity : BaseExplorerActivity() {
OperationFile(paths[i], types[i])
)
if (types[i] == Stat.S_IFDIR) {
remoteEncryptedVolume.recursiveMapFiles(paths[i])?.forEach {
srcEncryptedVolume.recursiveMapFiles(paths[i])?.forEach {
operationFiles.add(OperationFile.fromExplorerElement(it))
}
}
@ -64,17 +63,14 @@ class ExplorerActivity : BaseExplorerActivity() {
if (items != null) {
// stop loading thumbnails while writing files
explorerAdapter.loadThumbnails = false
taskScope.launch {
val failedItem = fileOperationService.copyElements(items, remoteEncryptedVolume)
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.success_import, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
activityScope.launch {
onTaskResult(
fileOperationService.copyElements(
volumeId,
items,
srcVolumeId
), R.string.import_failed, R.string.success_import
)
explorerAdapter.loadThumbnails = true
setCurrentPath(currentDirectoryPath)
}
@ -86,81 +82,62 @@ class ExplorerActivity : BaseExplorerActivity() {
}
private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris != null) {
importFilesFromUris(uris){ failedItem ->
onImportComplete(failedItem, uris)
for (uri in uris) {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
importFilesFromUris(uris) {
onImportComplete(uris)
}
}
}
private val pickExportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
taskScope.launch {
val result = fileOperationService.exportFiles(uri, explorerAdapter.selectedItems.map { i -> explorerElements[i] })
if (!result.cancelled) {
if (result.failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.success_export, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.export_failed, result.failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
}
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
activityScope.launch {
val result = fileOperationService.exportFiles(volumeId, items, uri)
onTaskResult(result, R.string.export_failed, R.string.success_export)
}
}
unselectAll()
}
private val pickImportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { rootUri ->
rootUri?.let {
contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
val tree = DocumentFile.fromTreeUri(this, it)!! //non-null after Lollipop
val operation = OperationFile(PathUtils.pathJoin(currentDirectoryPath, tree.name!!), Stat.S_IFDIR)
checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation ->
checkedOperation?.let {
taskScope.launch {
val result = fileOperationService.importDirectory(checkedOperation[0].dstPath!!, tree)
if (result.taskResult.cancelled) {
setCurrentPath(currentDirectoryPath)
} else {
onImportComplete(result.taskResult.failedItem, result.uris, tree)
activityScope.launch {
val result = fileOperationService.importDirectory(volumeId, checkedOperation[0].dstPath!!, tree)
onTaskResult(result.taskResult, R.string.import_failed) {
onImportComplete(result.uris, tree)
}
setCurrentPath(currentDirectoryPath)
}
}
}
}
}
private fun onImportComplete(failedItem: String?, urisToWipe: List<Uri>, rootFile: DocumentFile? = null) {
if (failedItem == null){
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage("""
${getString(R.string.success_import_msg)}
${getString(R.string.ask_for_wipe)}
""".trimIndent())
.setPositiveButton(R.string.yes) { _, _ ->
taskScope.launch {
val errorMsg = fileOperationService.wipeUris(urisToWipe, rootFile)
if (errorMsg == null) {
Toast.makeText(this@ExplorerActivity, R.string.wipe_successful, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.wipe_failed, errorMsg))
.setPositiveButton(R.string.ok, null)
.show()
}
}
private fun onImportComplete(urisToWipe: List<Uri>, rootFile: DocumentFile? = null) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage("""
${getString(R.string.success_import_msg)}
${getString(R.string.ask_for_wipe)}
""".trimIndent())
.setPositiveButton(R.string.yes) { _, _ ->
activityScope.launch {
onTaskResult(
fileOperationService.wipeUris(urisToWipe, rootFile),
R.string.wipe_failed,
R.string.wipe_successful,
)
}
.setNegativeButton(R.string.no, null)
.show()
} else {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
setCurrentPath(currentDirectoryPath)
}
.setNegativeButton(R.string.no, null)
.show()
}
override fun init() {
@ -324,22 +301,16 @@ class ExplorerActivity : BaseExplorerActivity() {
}
R.id.validate -> {
if (currentItemAction == ItemsActions.COPY){
checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items ->
items?.let {
taskScope.launch {
val failedItem = fileOperationService.copyElements(it.toMutableList() as ArrayList<OperationFile>)
if (!isFinishing) {
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.copy_success, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
setCurrentPath(currentDirectoryPath)
}
checkPathOverwrite(itemsToProcess, currentDirectoryPath) {
// copying before being cleared
it?.toMutableList()?.let { items ->
activityScope.launch {
onTaskResult(
fileOperationService.copyElements(volumeId, items),
R.string.copy_failed,
R.string.copy_success,
)
setCurrentPath(currentDirectoryPath)
}
}
cancelItemAction()
@ -357,17 +328,12 @@ class ExplorerActivity : BaseExplorerActivity() {
toMove,
toClean,
) {
taskScope.launch {
val failedItem = fileOperationService.moveElements(toMove, toClean)
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.move_success, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.move_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
activityScope.launch {
onTaskResult(
fileOperationService.moveElements(volumeId, toMove, toClean),
R.string.move_success,
R.string.move_failed,
)
setCurrentPath(currentDirectoryPath)
}
cancelItemAction()
@ -381,8 +347,9 @@ class ExplorerActivity : BaseExplorerActivity() {
val dialog = CustomAlertDialogBuilder(this, theme)
dialog.setTitle(R.string.warning)
dialog.setPositiveButton(R.string.ok) { _, _ ->
taskScope.launch {
fileOperationService.removeElements(explorerAdapter.selectedItems.map { i -> explorerElements[i] })?.let { failedItem ->
val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
activityScope.launch {
fileOperationService.removeElements(volumeId, items)?.let { failedItem ->
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, failedItem))

View File

@ -73,23 +73,15 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
}
}
private fun onImported(failedItem: String?){
private fun onImported() {
setCurrentPath(currentDirectoryPath)
if (failedItem == null) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage(R.string.success_import_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok){_, _ ->
finish()
}
.show()
} else {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage(R.string.success_import_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ ->
finish()
}
.show()
}
}

View File

@ -14,7 +14,7 @@ class ExplorerActivityPick : BaseExplorerActivity() {
private var isFinishingIntentionally = false
override fun init() {
setContentView(R.layout.activity_explorer_pick)
resultIntent.putExtra("volume", encryptedVolume)
resultIntent.putExtra("volumeId", volumeId)
}
override fun bindFileOperationService() {

View File

@ -2,7 +2,6 @@ package sushi.hardcore.droidfs.explorers
import android.content.Context
import android.content.Intent
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.IntentUtils
class ExplorerRouter(private val context: Context, private val intent: Intent) {
@ -16,7 +15,6 @@ class ExplorerRouter(private val context: Context, private val intent: Intent) {
IntentUtils.forwardIntent(intent, explorerIntent)
} else if (pickMode) {
explorerIntent = Intent(context, ExplorerActivityPick::class.java)
explorerIntent.putExtra("destinationVolume", IntentUtils.getParcelableExtra<EncryptedVolume>(intent, "volume")!!)
explorerIntent.flags = Intent.FLAG_ACTIVITY_FORWARD_RESULT
}
if (explorerIntent == null) {

View File

@ -13,9 +13,18 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManager
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.ObjRef
@ -31,19 +40,18 @@ class FileOperationService : Service() {
}
private val binder = LocalBinder()
private lateinit var encryptedVolume: EncryptedVolume
private lateinit var volumeManger: VolumeManager
private var serviceScope = MainScope()
private lateinit var notificationManager: NotificationManagerCompat
private val tasks = HashMap<Int, Job>()
private var lastNotificationId = 0
inner class LocalBinder : Binder() {
fun getService(): FileOperationService = this@FileOperationService
fun setEncryptedVolume(volume: EncryptedVolume) {
encryptedVolume = volume
}
}
override fun onBind(p0: Intent?): IBinder {
volumeManger = (application as VolumeManagerApp).volumeManager
return binder
}
@ -110,29 +118,55 @@ class FileOperationService : Service() {
tasks[notificationId]?.cancel()
}
class TaskResult<T>(val cancelled: Boolean, val failedItem: T?)
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<T> {
tasks[notification.notificationId] = task
return try {
TaskResult(false, task.await())
} catch (e: CancellationException) {
TaskResult(true, null)
} finally {
cancelNotification(notification)
}
private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
}
private fun copyFile(srcPath: String, dstPath: String, remoteEncryptedVolume: EncryptedVolume = encryptedVolume): Boolean {
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<out T> {
tasks[notification.notificationId] = task
return serviceScope.async {
try {
TaskResult.completed(task.await())
} catch (e: CancellationException) {
TaskResult.cancelled()
} catch (e: Throwable) {
e.printStackTrace()
TaskResult.error(e.localizedMessage)
} finally {
cancelNotification(notification)
}
}.await()
}
private suspend fun <T> volumeTask(
volumeId: Int,
notification: FileOperationNotification,
task: suspend (encryptedVolume: EncryptedVolume) -> T
): TaskResult<out T> {
return waitForTask(
notification,
volumeManger.getCoroutineScope(volumeId).async {
task(getEncryptedVolume(volumeId))
}
)
}
private suspend fun copyFile(
encryptedVolume: EncryptedVolume,
srcPath: String,
dstPath: String,
srcEncryptedVolume: EncryptedVolume = encryptedVolume,
): Boolean {
var success = true
val srcFileHandle = remoteEncryptedVolume.openFileReadMode(srcPath)
val srcFileHandle = srcEncryptedVolume.openFileReadMode(srcPath)
if (srcFileHandle != -1L) {
val dstFileHandle = encryptedVolume.openFileWriteMode(dstPath)
if (dstFileHandle != -1L) {
var offset: Long = 0
val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
var length: Long
while (remoteEncryptedVolume.read(srcFileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it.toLong() } > 0) {
while (srcEncryptedVolume.read(srcFileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it.toLong() } > 0) {
yield()
val written = encryptedVolume.write(dstFileHandle, offset, ioBuffer, 0, length).toLong()
if (written == length) {
offset += written
@ -146,7 +180,7 @@ class FileOperationService : Service() {
} else {
success = false
}
remoteEncryptedVolume.closeFile(srcFileHandle)
srcEncryptedVolume.closeFile(srcFileHandle)
} else {
success = false
}
@ -154,25 +188,24 @@ class FileOperationService : Service() {
}
suspend fun copyElements(
items: ArrayList<OperationFile>,
remoteEncryptedVolume: EncryptedVolume = encryptedVolume
): String? = coroutineScope {
volumeId: Int,
items: List<OperationFile>,
srcVolumeId: Int = volumeId,
): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_copy_msg, items.size)
val task = async {
val srcEncryptedVolume = getEncryptedVolume(srcVolumeId)
return volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null
for (i in 0 until items.size) {
withContext(Dispatchers.IO) {
if (items[i].isDirectory) {
if (!encryptedVolume.pathExists(items[i].dstPath!!)) {
if (!encryptedVolume.mkdir(items[i].dstPath!!)) {
failedItem = items[i].srcPath
}
}
} else {
if (!copyFile(items[i].srcPath, items[i].dstPath!!, remoteEncryptedVolume)) {
for (i in items.indices) {
yield()
if (items[i].isDirectory) {
if (!encryptedVolume.pathExists(items[i].dstPath!!)) {
if (!encryptedVolume.mkdir(items[i].dstPath!!)) {
failedItem = items[i].srcPath
}
}
} else if (!copyFile(encryptedVolume, items[i].srcPath, items[i].dstPath!!, srcEncryptedVolume)) {
failedItem = items[i].srcPath
}
if (failedItem == null) {
updateNotificationProgress(notification, i+1, items.size)
@ -182,13 +215,11 @@ class FileOperationService : Service() {
}
failedItem
}
// treat cancellation as success
waitForTask(notification, task).failedItem
}
suspend fun moveElements(toMove: List<OperationFile>, toClean: List<String>): String? = coroutineScope {
suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_move_msg, toMove.size)
val task = async(Dispatchers.IO) {
return volumeTask(volumeId, notification) { encryptedVolume ->
val total = toMove.size+toClean.size
var failedItem: String? = null
for ((i, item) in toMove.withIndex()) {
@ -211,25 +242,23 @@ class FileOperationService : Service() {
}
failedItem
}
// treat cancellation as success
waitForTask(notification, task).failedItem
}
private suspend fun importFilesFromUris(
encryptedVolume: EncryptedVolume,
dstPaths: List<String>,
uris: List<Uri>,
notification: FileOperationNotification,
): String? {
var failedIndex = -1
for (i in dstPaths.indices) {
withContext(Dispatchers.IO) {
try {
if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
failedIndex = i
}
} catch (e: FileNotFoundException) {
yield()
try {
if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
failedIndex = i
}
} catch (e: FileNotFoundException) {
failedIndex = i
}
if (failedIndex == -1) {
updateNotificationProgress(notification, i+1, dstPaths.size)
@ -240,12 +269,11 @@ class FileOperationService : Service() {
return null
}
suspend fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>): TaskResult<String?> = coroutineScope {
suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_import_msg, dstPaths.size)
val task = async {
importFilesFromUris(dstPaths, uris, notification)
return volumeTask(volumeId, notification) { encryptedVolume ->
importFilesFromUris(encryptedVolume, dstPaths, uris, notification)
}
waitForTask(notification, task)
}
/**
@ -255,77 +283,63 @@ class FileOperationService : Service() {
*
* @return false if cancelled early, true otherwise.
*/
private fun recursiveMapDirectoryForImport(
private suspend fun recursiveMapDirectoryForImport(
rootSrcDir: DocumentFile,
rootDstPath: String,
dstFiles: ArrayList<String>,
srcUris: ArrayList<Uri>,
dstDirs: ArrayList<String>,
scope: CoroutineScope,
): Boolean {
) {
dstDirs.add(rootDstPath)
for (child in rootSrcDir.listFiles()) {
if (!scope.isActive) {
return false
}
yield()
child.name?.let { name ->
val subPath = PathUtils.pathJoin(rootDstPath, name)
if (child.isDirectory) {
if (!recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs, scope)) {
return false
}
}
else if (child.isFile) {
recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs)
} else if (child.isFile) {
srcUris.add(child.uri)
dstFiles.add(subPath)
}
}
}
return true
}
class ImportDirectoryResult(val taskResult: TaskResult<String?>, val uris: List<Uri>)
class ImportDirectoryResult(val taskResult: TaskResult<out String?>, val uris: List<Uri>)
suspend fun importDirectory(
volumeId: Int,
rootDstPath: String,
rootSrcDir: DocumentFile,
): ImportDirectoryResult = coroutineScope {
): ImportDirectoryResult {
val notification = showNotification(R.string.file_op_import_msg, null)
val srcUris = arrayListOf<Uri>()
val task = async {
return ImportDirectoryResult(volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null
val dstFiles = arrayListOf<String>()
val dstDirs = arrayListOf<String>()
withContext(Dispatchers.IO) {
if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, this)) {
return@withContext
}
// create destination folders so the new files can use them
for (dir in dstDirs) {
if (!encryptedVolume.mkdir(dir)) {
failedItem = dir
break
}
recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs)
// create destination folders so the new files can use them
for (dir in dstDirs) {
if (!encryptedVolume.mkdir(dir)) {
failedItem = dir
break
}
}
if (failedItem == null) {
failedItem = importFilesFromUris(dstFiles, srcUris, notification)
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, notification)
}
failedItem
}
ImportDirectoryResult(waitForTask(notification, task), srcUris)
}, srcUris)
}
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): String? = coroutineScope {
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_wiping_msg, uris.size)
val task = async {
val task = serviceScope.async(Dispatchers.IO) {
var errorMsg: String? = null
for (i in uris.indices) {
withContext(Dispatchers.IO) {
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
}
yield()
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
if (errorMsg == null) {
updateNotificationProgress(notification, i+1, uris.size)
} else {
@ -337,11 +351,10 @@ class FileOperationService : Service() {
}
errorMsg
}
// treat cancellation as success
waitForTask(notification, task).failedItem
return waitForTask(notification, task)
}
private fun exportFileInto(srcPath: String, treeDocumentFile: DocumentFile): Boolean {
private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
val outputStream = treeDocumentFile.createFile("*/*", File(srcPath).name)?.uri?.let {
contentResolver.openOutputStream(it)
}
@ -352,25 +365,20 @@ class FileOperationService : Service() {
}
}
private fun recursiveExportDirectory(
private suspend fun recursiveExportDirectory(
encryptedVolume: EncryptedVolume,
plain_directory_path: String,
treeDocumentFile: DocumentFile,
scope: CoroutineScope
): String? {
treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree ->
val explorerElements = encryptedVolume.readDir(plain_directory_path) ?: return null
for (e in explorerElements) {
if (!scope.isActive) {
return null
}
yield()
val fullPath = PathUtils.pathJoin(plain_directory_path, e.name)
if (e.isDirectory) {
val failedItem = recursiveExportDirectory(fullPath, childTree, scope)
failedItem?.let { return it }
} else {
if (!exportFileInto(fullPath, childTree)){
return fullPath
}
recursiveExportDirectory(encryptedVolume, fullPath, childTree)?.let { return it }
} else if (!exportFileInto(encryptedVolume, fullPath, childTree)) {
return fullPath
}
}
return null
@ -378,18 +386,20 @@ class FileOperationService : Service() {
return treeDocumentFile.name
}
suspend fun exportFiles(uri: Uri, items: List<ExplorerElement>): TaskResult<String?> = coroutineScope {
suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_export_msg, items.size)
val task = async {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
return volumeTask(volumeId, notification) { encryptedVolume ->
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
var failedItem: String? = null
for (i in items.indices) {
withContext(Dispatchers.IO) {
failedItem = if (items[i].isDirectory) {
recursiveExportDirectory(items[i].fullPath, treeDocumentFile, this)
yield()
failedItem = if (items[i].isDirectory) {
recursiveExportDirectory(encryptedVolume, items[i].fullPath, treeDocumentFile)
} else {
if (exportFileInto(encryptedVolume, items[i].fullPath, treeDocumentFile)) {
null
} else {
if (exportFileInto(items[i].fullPath, treeDocumentFile)) null else items[i].fullPath
items[i].fullPath
}
}
if (failedItem == null) {
@ -400,21 +410,37 @@ class FileOperationService : Service() {
}
failedItem
}
waitForTask(notification, task)
}
suspend fun removeElements(items: List<ExplorerElement>): String? = coroutineScope {
private suspend fun recursiveRemoveDirectory(encryptedVolume: EncryptedVolume, path: String): String? {
encryptedVolume.readDir(path)?.let { elements ->
for (e in elements) {
yield()
val fullPath = PathUtils.pathJoin(path, e.name)
if (e.isDirectory) {
recursiveRemoveDirectory(encryptedVolume, fullPath)?.let { return it }
} else if (!encryptedVolume.deleteFile(fullPath)) {
return fullPath
}
}
}
return if (!encryptedVolume.rmdir(path)) {
path
} else {
null
}
}
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
val notification = showNotification(R.string.file_op_delete_msg, items.size)
val task = async(Dispatchers.IO) {
return volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null
for ((i, element) in items.withIndex()) {
yield()
if (element.isDirectory) {
val result = encryptedVolume.recursiveRemoveDirectory(element.fullPath)
result?.let { failedItem = it }
} else {
if (!encryptedVolume.deleteFile(element.fullPath)) {
failedItem = element.fullPath
}
recursiveRemoveDirectory(encryptedVolume, element.fullPath)?.let { failedItem = it }
} else if (!encryptedVolume.deleteFile(element.fullPath)) {
failedItem = element.fullPath
}
if (failedItem == null) {
updateNotificationProgress(notification, i + 1, items.size)
@ -423,14 +449,11 @@ class FileOperationService : Service() {
}
}
failedItem
}
waitForTask(notification, task).failedItem
}.failedItem // treat cancellation as success
}
private fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int {
if (!scope.isActive) {
return 0
}
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int {
yield()
val children = rootDirectory.listFiles()
var count = children.size
for (child in children) {
@ -441,7 +464,7 @@ class FileOperationService : Service() {
return count
}
private fun recursiveCopyVolume(
private suspend fun recursiveCopyVolume(
src: DocumentFile,
dst: DocumentFile,
dstRootDirectory: ObjRef<DocumentFile?>?,
@ -453,9 +476,7 @@ class FileOperationService : Service() {
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
dstRootDirectory?.let { it.value = dstDir }
for (child in src.listFiles()) {
if (!scope.isActive) {
return null
}
yield()
if (child.isFile) {
val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child
val outputStream = contentResolver.openOutputStream(dstFile.uri)
@ -474,21 +495,16 @@ class FileOperationService : Service() {
return null
}
class CopyVolumeResult(val taskResult: TaskResult<DocumentFile?>, val dstRootDirectory: DocumentFile?)
class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult = coroutineScope {
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
val notification = showNotification(R.string.copy_volume_notification, null)
val dstRootDirectory = ObjRef<DocumentFile?>(null)
val task = async(Dispatchers.IO) {
val task = serviceScope.async(Dispatchers.IO) {
val total = recursiveCountChildElements(src, this)
if (isActive) {
updateNotificationProgress(notification, 0, total)
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this)
} else {
null
}
updateNotificationProgress(notification, 0, total)
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this)
}
// treat cancellation as success
CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
return CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
}
}

View File

@ -0,0 +1,47 @@
package sushi.hardcore.droidfs.file_operations
import android.content.Context
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.Theme
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
class TaskResult<T> private constructor(val state: State, val failedItem: T?, val errorMessage: String?) {
enum class State {
SUCCESS,
/**
* Task completed but failed
*/
FAILED,
/**
* Task thrown an exception
*/
ERROR,
CANCELLED,
}
fun showErrorAlertDialog(context: Context, theme: Theme) {
CustomAlertDialogBuilder(context, theme)
.setTitle(R.string.error)
.setMessage(context.getString(R.string.task_failed, errorMessage))
.setPositiveButton(R.string.ok, null)
.show()
}
companion object {
fun <T> completed(failedItem: T?): TaskResult<T> {
return if (failedItem == null) {
TaskResult(State.SUCCESS, null, null)
} else {
TaskResult(State.FAILED, failedItem, null)
}
}
fun <T> error(errorMessage: String?): TaskResult<T> {
return TaskResult(State.ERROR, null, errorMessage)
}
fun <T> cancelled(): TaskResult<T> {
return TaskResult(State.CANCELLED, null, null)
}
}
}

View File

@ -218,25 +218,4 @@ abstract class EncryptedVolume: Parcelable {
}
return result
}
fun recursiveRemoveDirectory(path: String): String? {
readDir(path)?.let { elements ->
for (e in elements) {
val fullPath = PathUtils.pathJoin(path, e.name)
if (e.isDirectory) {
val result = recursiveRemoveDirectory(fullPath)
result?.let { return it }
} else {
if (!deleteFile(fullPath)) {
return fullPath
}
}
}
}
return if (!rmdir(path)) {
path
} else {
null
}
}
}

View File

@ -261,4 +261,5 @@
<string name="config_load_error">The configuration file cannot be loaded. Make sure the volume is accessible.</string>
<string name="wrong_password">The configuration file cannot be decrypted. Please check your password.</string>
<string name="filesystem_id_changed">The filesystem id in the config file is different to the last time we opened this volume. This could mean an attacker replaced the filesystem with a different one.</string>
<string name="task_failed">The task failed: %s</string>
</resources>