diff --git a/app/src/main/java/sushi/hardcore/droidfs/CameraActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/CameraActivity.kt index 77e83a0..feb1f75 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/CameraActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/CameraActivity.kt @@ -25,6 +25,9 @@ import androidx.camera.extensions.ExtensionsManager import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider import sushi.hardcore.droidfs.databinding.ActivityCameraBinding import sushi.hardcore.droidfs.util.PathUtils @@ -386,19 +389,17 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { if (timerDuration > 0){ binding.textTimer.visibility = View.VISIBLE isWaitingForTimer = true - Thread{ + lifecycleScope.launch { for (i in timerDuration downTo 1){ - runOnUiThread { binding.textTimer.text = i.toString() } - Thread.sleep(1000) + binding.textTimer.text = i.toString() + delay(1000) } if (!isFinishing) { - runOnUiThread { - action() - binding.textTimer.visibility = View.GONE - } + action() + binding.textTimer.visibility = View.GONE } isWaitingForTimer = false - }.start() + } } else { action() } diff --git a/app/src/main/java/sushi/hardcore/droidfs/ChangePasswordActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/ChangePasswordActivity.kt index e7a430e..5837e33 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/ChangePasswordActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/ChangePasswordActivity.kt @@ -7,7 +7,7 @@ import android.text.InputType import android.view.MenuItem import android.view.View import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import sushi.hardcore.droidfs.databinding.ActivityChangePasswordBinding import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import java.util.* @@ -105,62 +105,63 @@ class ChangePasswordActivity: BaseActivity() { } private fun changeVolumePassword(newPassword: CharArray, givenHash: ByteArray? = null) { - object : LoadingTask(this, themeValue, R.string.loading_msg_change_password) { - override fun doTask(activity: AppCompatActivity) { - var returnedHash: ByteArray? = null - if (binding.checkboxSavePassword.isChecked) { - returnedHash = ByteArray(GocryptfsVolume.KeyLen) - } - var currentPassword: CharArray? = null - if (givenHash == null) { - currentPassword = CharArray(binding.editCurrentPassword.text.length) - binding.editCurrentPassword.text.getChars(0, currentPassword.size, currentPassword, 0) - } - if (GocryptfsVolume.changePassword(volume.getFullPath(filesDir.path), currentPassword, givenHash, newPassword, returnedHash)) { + var returnedHash: ByteArray? = null + if (binding.checkboxSavePassword.isChecked) { + returnedHash = ByteArray(GocryptfsVolume.KeyLen) + } + var currentPassword: CharArray? = null + if (givenHash == null) { + currentPassword = CharArray(binding.editCurrentPassword.text.length) + binding.editCurrentPassword.text.getChars(0, currentPassword.size, currentPassword, 0) + } + object : LoadingTask(this, themeValue, R.string.loading_msg_change_password) { + override suspend fun doTask(): Boolean { + val success = GocryptfsVolume.changePassword(volume.getFullPath(filesDir.path), currentPassword, givenHash, newPassword, returnedHash) + if (success) { if (volumeDatabase.isHashSaved(volume.name)) { volumeDatabase.removeHash(volume) } - stopTask { - @SuppressLint("NewApi") // if fingerprintProtector is null checkboxSavePassword is hidden - if (binding.checkboxSavePassword.isChecked && returnedHash != null) { - fingerprintProtector!!.let { - it.listener = object : FingerprintProtector.Listener { - override fun onHashStorageReset() { - // retry - it.savePasswordHash(volume, returnedHash) - } - override fun onPasswordHashDecrypted(hash: ByteArray) {} - override fun onPasswordHashSaved() { - Arrays.fill(returnedHash, 0) - finish() - } - override fun onFailed(pending: Boolean) { - if (!pending) { - Arrays.fill(returnedHash, 0) - finish() - } - } - } - it.savePasswordHash(volume, returnedHash) - } - } else { - finish() - } - } - } else { - stopTask { - CustomAlertDialogBuilder(activity, themeValue) - .setTitle(R.string.error) - .setMessage(R.string.change_password_failed) - .setPositiveButton(R.string.ok, null) - .show() - } } if (currentPassword != null) Arrays.fill(currentPassword, 0.toChar()) Arrays.fill(newPassword, 0.toChar()) if (givenHash != null) Arrays.fill(givenHash, 0) + return success + } + }.startTask(lifecycleScope) { success -> + if (success) { + @SuppressLint("NewApi") // if fingerprintProtector is null checkboxSavePassword is hidden + if (binding.checkboxSavePassword.isChecked && returnedHash != null) { + fingerprintProtector!!.let { + it.listener = object : FingerprintProtector.Listener { + override fun onHashStorageReset() { + // retry + it.savePasswordHash(volume, returnedHash) + } + override fun onPasswordHashDecrypted(hash: ByteArray) {} + override fun onPasswordHashSaved() { + Arrays.fill(returnedHash, 0) + finish() + } + override fun onFailed(pending: Boolean) { + if (!pending) { + Arrays.fill(returnedHash, 0) + finish() + } + } + } + it.savePasswordHash(volume, returnedHash) + } + } else { + finish() + } + } else { + CustomAlertDialogBuilder(this, themeValue) + .setTitle(R.string.error) + .setMessage(R.string.change_password_failed) + .setPositiveButton(R.string.ok, null) + .show() } } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/LoadingTask.kt b/app/src/main/java/sushi/hardcore/droidfs/LoadingTask.kt index 157022e..4454740 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/LoadingTask.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/LoadingTask.kt @@ -1,39 +1,34 @@ package sushi.hardcore.droidfs -import android.widget.TextView -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sushi.hardcore.droidfs.databinding.DialogLoadingBinding import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder -abstract class LoadingTask(val activity: AppCompatActivity, themeValue: String, loadingMessageResId: Int) { - private val dialogLoadingView = activity.layoutInflater.inflate(R.layout.dialog_loading, null) - private val dialogLoading: AlertDialog = CustomAlertDialogBuilder(activity, themeValue) - .setView(dialogLoadingView) +abstract class LoadingTask(val activity: AppCompatActivity, themeValue: String, loadingMessageResId: Int) { + private val dialogLoading = CustomAlertDialogBuilder(activity, themeValue) + .setView( + DialogLoadingBinding.inflate(activity.layoutInflater).apply { + textMessage.text = activity.getString(loadingMessageResId) + }.root + ) .setTitle(R.string.loading) .setCancelable(false) .create() - private var isStopped = false - init { - dialogLoadingView.findViewById(R.id.text_message).text = activity.getString(loadingMessageResId) - startTask() - } - abstract fun doTask(activity: AppCompatActivity) - private fun startTask() { + + abstract suspend fun doTask(): T + + fun startTask(scope: CoroutineScope, onDone: (T) -> Unit) { dialogLoading.show() - Thread { - doTask(activity) - if (!isStopped){ - dialogLoading.dismiss() - } - }.start() - } - fun stopTask(onUiThread: (() -> Unit)?){ - isStopped = true - dialogLoading.dismiss() - onUiThread?.let { - activity.runOnUiThread { - onUiThread() + scope.launch { + val result = withContext(Dispatchers.IO) { + doTask() } + dialogLoading.dismiss() + onDone(result) } } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt index 64c997e..2dac188 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt @@ -16,9 +16,10 @@ import android.view.View import android.view.WindowManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.launch import sushi.hardcore.droidfs.adapters.VolumeAdapter import sushi.hardcore.droidfs.add_volume.AddVolumeActivity import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider @@ -34,7 +35,6 @@ import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.EditTextDialog import java.io.File import java.util.* -import kotlin.NoSuchElementException class MainActivity : BaseActivity(), VolumeAdapter.Listener { @@ -389,20 +389,25 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener { } private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> Volume?) { - fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile) { dstRootDirectory, failedItem -> - runOnUiThread { - if (failedItem == null) { - dstRootDirectory?.let { + lifecycleScope.launch { + val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile) + when { + result.taskResult.cancelled -> { + result.dstRootDirectory?.delete() + } + result.taskResult.failedItem == null -> { + result.dstRootDirectory?.let { getResultVolume(it)?.let { volume -> volumeDatabase.saveVolume(volume) onVolumeAdded() + Toast.makeText(this@MainActivity, R.string.copy_success, Toast.LENGTH_SHORT).show() } } - Toast.makeText(this, R.string.copy_success, Toast.LENGTH_SHORT).show() - } else { - CustomAlertDialogBuilder(this, themeValue) + } + else -> { + CustomAlertDialogBuilder(this@MainActivity, themeValue) .setTitle(R.string.error) - .setMessage(getString(R.string.copy_failed, failedItem.name)) + .setMessage(getString(R.string.copy_failed, result.taskResult.failedItem.name)) .setPositiveButton(R.string.ok, null) .show() } @@ -458,20 +463,21 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener { volumeAdapter.refresh() } override fun onPasswordHashDecrypted(hash: ByteArray) { - object : LoadingTask(this@MainActivity, themeValue, R.string.loading_msg_open) { - override fun doTask(activity: AppCompatActivity) { + object : LoadingTask(this@MainActivity, themeValue, R.string.loading_msg_open) { + override suspend fun doTask(): Int { val sessionId = GocryptfsVolume.init(volume.getFullPath(filesDir.path), null, hash, null) Arrays.fill(hash, 0) - if (sessionId != -1) - stopTask { startExplorer(sessionId, volume.shortName) } - else - stopTask { - CustomAlertDialogBuilder(activity, themeValue) - .setTitle(R.string.open_volume_failed) - .setMessage(R.string.open_failed_hash_msg) - .setPositiveButton(R.string.ok, null) - .show() - } + return sessionId + } + }.startTask(lifecycleScope) { sessionId -> + if (sessionId != -1) { + startExplorer(sessionId, volume.shortName) + } else { + CustomAlertDialogBuilder(this@MainActivity, themeValue) + .setTitle(R.string.open_volume_failed) + .setMessage(R.string.open_failed_hash_msg) + .setPositiveButton(R.string.ok, null) + .show() } } } @@ -544,53 +550,53 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener { private fun openVolumeWithPassword(volume: Volume, position: Int, password: CharArray, savePasswordHash: Boolean) { val usfFingerprint = sharedPrefs.getBoolean("usf_fingerprint", false) - object : LoadingTask(this, themeValue, R.string.loading_msg_open) { - override fun doTask(activity: AppCompatActivity) { - var returnedHash: ByteArray? = null - if (savePasswordHash && usfFingerprint) { - returnedHash = ByteArray(GocryptfsVolume.KeyLen) - } + var returnedHash: ByteArray? = null + if (savePasswordHash && usfFingerprint) { + returnedHash = ByteArray(GocryptfsVolume.KeyLen) + } + object : LoadingTask(this, themeValue, R.string.loading_msg_open) { + override suspend fun doTask(): Int { val sessionId = GocryptfsVolume.init(volume.getFullPath(filesDir.path), password, null, returnedHash) Arrays.fill(password, 0.toChar()) - if (sessionId != -1) { - val fingerprintProtector = fingerprintProtector - @SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23 - if (savePasswordHash && returnedHash != null && fingerprintProtector != null) - stopTask { - fingerprintProtector.listener = object : FingerprintProtector.Listener { - override fun onHashStorageReset() { - volumeAdapter.refresh() - } - override fun onPasswordHashDecrypted(hash: ByteArray) {} - override fun onPasswordHashSaved() { - Arrays.fill(returnedHash, 0) - volumeAdapter.onVolumeChanged(position) - startExplorer(sessionId, volume.shortName) - } - private var isClosed = false - override fun onFailed(pending: Boolean) { - if (!isClosed) { - GocryptfsVolume(this@MainActivity, sessionId).close() - isClosed = true - } - Arrays.fill(returnedHash, 0) - } - } - fingerprintProtector.savePasswordHash(volume, returnedHash) + return sessionId + } + }.startTask(lifecycleScope) { sessionId -> + if (sessionId != -1) { + val fingerprintProtector = fingerprintProtector + @SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23 + if (savePasswordHash && returnedHash != null && fingerprintProtector != null) { + fingerprintProtector.listener = object : FingerprintProtector.Listener { + override fun onHashStorageReset() { + volumeAdapter.refresh() } - else - stopTask { startExplorer(sessionId, volume.shortName) } - } else - stopTask { - CustomAlertDialogBuilder(activity, themeValue) - .setTitle(R.string.open_volume_failed) - .setMessage(R.string.open_volume_failed_msg) - .setPositiveButton(R.string.ok, null) - .setOnDismissListener { - askForPassword(volume, position) + override fun onPasswordHashDecrypted(hash: ByteArray) {} + override fun onPasswordHashSaved() { + Arrays.fill(returnedHash, 0) + volumeAdapter.onVolumeChanged(position) + startExplorer(sessionId, volume.shortName) + } + private var isClosed = false + override fun onFailed(pending: Boolean) { + if (!isClosed) { + GocryptfsVolume(this@MainActivity, sessionId).close() + isClosed = true } - .show() + Arrays.fill(returnedHash, 0) + } } + fingerprintProtector.savePasswordHash(volume, returnedHash) + } else { + startExplorer(sessionId, volume.shortName) + } + } else { + CustomAlertDialogBuilder(this, themeValue) + .setTitle(R.string.open_volume_failed) + .setMessage(R.string.open_volume_failed_msg) + .setPositiveButton(R.string.ok, null) + .setOnDismissListener { + askForPassword(volume, position) + } + .show() } } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/adapters/ExplorerElementAdapter.kt b/app/src/main/java/sushi/hardcore/droidfs/adapters/ExplorerElementAdapter.kt index 37eab76..d36f82c 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/adapters/ExplorerElementAdapter.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/adapters/ExplorerElementAdapter.kt @@ -15,6 +15,7 @@ import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.request.target.DrawableImageViewTarget import com.bumptech.glide.request.transition.Transition +import kotlinx.coroutines.* import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.GocryptfsVolume import sushi.hardcore.droidfs.R @@ -112,33 +113,40 @@ class ExplorerElementAdapter( } class FileViewHolder(itemView: View) : RegularElementViewHolder(itemView) { - var displayThumbnail = true - var target: DrawableImageViewTarget? = null + private var target: DrawableImageViewTarget? = null + private var job: Job? = null + private val scope = CoroutineScope(Dispatchers.IO) private fun loadThumbnail(fullPath: String, adapter: ExplorerElementAdapter) { adapter.gocryptfsVolume?.let { volume -> - displayThumbnail = true - Thread { + job = scope.launch { volume.loadWholeFile(fullPath, maxSize = adapter.thumbnailMaxSize).first?.let { - if (displayThumbnail) { - adapter.activity.runOnUiThread { - if (displayThumbnail && !adapter.activity.isFinishing) { + if (isActive) { + withContext(Dispatchers.Main) { + if (isActive && !adapter.activity.isFinishing) { target = Glide.with(adapter.activity).load(it).skipMemoryCache(true).into(object : DrawableImageViewTarget(icon) { override fun onResourceReady( resource: Drawable, transition: Transition? ) { + target = null val bitmap = resource.toBitmap() adapter.thumbnailsCache!!.put(fullPath, bitmap.copy(bitmap.config, true)) super.onResourceReady(resource, transition) - target = null } }) } } } } - }.start() + } + } + } + + fun cancelThumbnailLoading(adapter: ExplorerElementAdapter) { + job?.cancel() + target?.let { + Glide.with(adapter.activity).clear(it) } } @@ -199,11 +207,7 @@ class ExplorerElementAdapter( override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { if (holder is FileViewHolder) { - //cancel pending thumbnail display - holder.displayThumbnail = false - holder.target?.let { - Glide.with(activity).clear(it) - } + holder.cancelThumbnailLoading(this) } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/add_volume/CreateVolumeFragment.kt b/app/src/main/java/sushi/hardcore/droidfs/add_volume/CreateVolumeFragment.kt index 0c02524..c0cfd1e 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/add_volume/CreateVolumeFragment.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/add_volume/CreateVolumeFragment.kt @@ -12,6 +12,7 @@ import android.widget.ArrayAdapter import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import sushi.hardcore.droidfs.* import sushi.hardcore.droidfs.databinding.FragmentCreateVolumeBinding import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder @@ -123,9 +124,14 @@ class CreateVolumeFragment: Fragment() { if (!password.contentEquals(passwordConfirm)) { Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show() Arrays.fill(password, 0.toChar()) + Arrays.fill(passwordConfirm, 0.toChar()) } else { - object: LoadingTask(requireActivity() as AppCompatActivity, themeValue, R.string.loading_msg_create) { - override fun doTask(activity: AppCompatActivity) { + Arrays.fill(passwordConfirm, 0.toChar()) + var returnedHash: ByteArray? = null + if (binding.checkboxSavePassword.isChecked) + returnedHash = ByteArray(GocryptfsVolume.KeyLen) + object: LoadingTask(requireActivity() as AppCompatActivity, themeValue, R.string.loading_msg_create) { + override suspend fun doTask(): Volume? { val xchacha = when (binding.spinnerXchacha.selectedItemPosition) { 0 -> 0 1 -> 1 @@ -134,10 +140,16 @@ class CreateVolumeFragment: Fragment() { val volumeFile = File(volumePath) if (!volumeFile.exists()) volumeFile.mkdirs() - var returnedHash: ByteArray? = null - if (binding.checkboxSavePassword.isChecked) - returnedHash = ByteArray(GocryptfsVolume.KeyLen) - if (GocryptfsVolume.createVolume(volumePath, password, false, xchacha, GocryptfsVolume.ScryptDefaultLogN, ConstValues.CREATOR, returnedHash)) { + val volume = if (GocryptfsVolume.createVolume( + volumePath, + password, + false, + xchacha, + GocryptfsVolume.ScryptDefaultLogN, + ConstValues.CREATOR, + returnedHash + ) + ) { val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath val volume = Volume(volumeName, isHiddenVolume) volumeDatabase.apply { @@ -145,46 +157,48 @@ class CreateVolumeFragment: Fragment() { removeVolume(volumeName) saveVolume(volume) } - stopTask { - @SuppressLint("NewApi") // if fingerprintProtector is null checkboxSavePassword is hidden - if (binding.checkboxSavePassword.isChecked && returnedHash != null) { - fingerprintProtector!!.let { - it.listener = object : FingerprintProtector.Listener { - override fun onHashStorageReset() { - hashStorageReset = true - // retry - it.savePasswordHash(volume, returnedHash) - } - override fun onPasswordHashDecrypted(hash: ByteArray) {} // shouldn't happen here - override fun onPasswordHashSaved() { - Arrays.fill(returnedHash, 0) - onVolumeCreated() - } - override fun onFailed(pending: Boolean) { - if (!pending) { - Arrays.fill(returnedHash, 0) - onVolumeCreated() - } - } - } - it.savePasswordHash(volume, returnedHash) - } - } else onVolumeCreated() - } + volume } else { - stopTask { - CustomAlertDialogBuilder(activity, themeValue) - .setTitle(R.string.error) - .setMessage(R.string.create_volume_failed) - .setPositiveButton(R.string.ok, null) - .show() - } + null } Arrays.fill(password, 0.toChar()) + return volume + } + }.startTask(lifecycleScope) { volume -> + if (volume == null) { + CustomAlertDialogBuilder(requireContext(), themeValue) + .setTitle(R.string.error) + .setMessage(R.string.create_volume_failed) + .setPositiveButton(R.string.ok, null) + .show() + } else { + @SuppressLint("NewApi") // if fingerprintProtector is null checkboxSavePassword is hidden + if (binding.checkboxSavePassword.isChecked && returnedHash != null) { + fingerprintProtector!!.let { + it.listener = object : FingerprintProtector.Listener { + override fun onHashStorageReset() { + hashStorageReset = true + // retry + it.savePasswordHash(volume, returnedHash) + } + override fun onPasswordHashDecrypted(hash: ByteArray) {} // shouldn't happen here + override fun onPasswordHashSaved() { + Arrays.fill(returnedHash, 0) + onVolumeCreated() + } + override fun onFailed(pending: Boolean) { + if (!pending) { + Arrays.fill(returnedHash, 0) + onVolumeCreated() + } + } + } + it.savePasswordHash(volume, returnedHash) + } + } else onVolumeCreated() } } } - Arrays.fill(passwordConfirm, 0.toChar()) } private fun onVolumeCreated() { diff --git a/app/src/main/java/sushi/hardcore/droidfs/content_providers/ExternalProvider.kt b/app/src/main/java/sushi/hardcore/droidfs/content_providers/ExternalProvider.kt index b11381a..cee4e32 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/content_providers/ExternalProvider.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/content_providers/ExternalProvider.kt @@ -5,6 +5,10 @@ import android.content.Intent import android.net.Uri import android.webkit.MimeTypeMap import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import sushi.hardcore.droidfs.GocryptfsVolume import sushi.hardcore.droidfs.LoadingTask import sushi.hardcore.droidfs.R @@ -13,7 +17,7 @@ import java.io.File object ExternalProvider { private const val content_type_all = "*/*" - private var storedFiles: MutableList = ArrayList() + private var storedFiles = HashSet() private fun getContentType(filename: String, previous_content_type: String?): String { if (content_type_all != previous_content_type) { var contentType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(filename).extension) @@ -42,26 +46,23 @@ object ExternalProvider { } fun share(activity: AppCompatActivity, themeValue: String, gocryptfsVolume: GocryptfsVolume, file_paths: List) { - object : LoadingTask(activity, themeValue, R.string.loading_msg_export) { - override fun doTask(activity: AppCompatActivity) { - var contentType: String? = null - val uris = ArrayList() + var contentType: String? = null + val uris = ArrayList(file_paths.size) + object : LoadingTask(activity, themeValue, R.string.loading_msg_export) { + override suspend fun doTask(): String? { for (path in file_paths) { val result = exportFile(activity, gocryptfsVolume, path, contentType) contentType = if (result.first != null) { uris.add(result.first!!) result.second } else { - stopTask { - CustomAlertDialogBuilder(activity, themeValue) - .setTitle(R.string.error) - .setMessage(activity.getString(R.string.export_failed, path)) - .setPositiveButton(R.string.ok, null) - .show() - } - return + return path } } + return null + } + }.startTask(activity.lifecycleScope) { failedItem -> + if (failedItem == null) { val shareIntent = Intent() shareIntent.type = contentType if (uris.size == 1) { @@ -71,45 +72,51 @@ object ExternalProvider { shareIntent.action = Intent.ACTION_SEND_MULTIPLE shareIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) } - stopTask { - activity.startActivity(Intent.createChooser(shareIntent, activity.getString(R.string.share_chooser))) - } + activity.startActivity(Intent.createChooser(shareIntent, activity.getString(R.string.share_chooser))) + } else { + CustomAlertDialogBuilder(activity, themeValue) + .setTitle(R.string.error) + .setMessage(activity.getString(R.string.export_failed, failedItem)) + .setPositiveButton(R.string.ok, null) + .show() } } } fun open(activity: AppCompatActivity, themeValue: String, gocryptfsVolume: GocryptfsVolume, file_path: String) { - object : LoadingTask(activity, themeValue, R.string.loading_msg_export) { - override fun doTask(activity: AppCompatActivity) { + object : LoadingTask(activity, themeValue, R.string.loading_msg_export) { + override suspend fun doTask(): Intent? { val result = exportFile(activity, gocryptfsVolume, file_path, null) - if (result.first != null) { - val openIntent = Intent(Intent.ACTION_VIEW) - openIntent.setDataAndType(result.first, result.second) - stopTask { activity.startActivity(openIntent) } - } else { - stopTask { - CustomAlertDialogBuilder(activity, themeValue) - .setTitle(R.string.error) - .setMessage(activity.getString(R.string.export_failed, file_path)) - .setPositiveButton(R.string.ok, null) - .show() + return if (result.first != null) { + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(result.first, result.second) } + } else { + null } } + }.startTask(activity.lifecycleScope) { openIntent -> + if (openIntent == null) { + CustomAlertDialogBuilder(activity, themeValue) + .setTitle(R.string.error) + .setMessage(activity.getString(R.string.export_failed, file_path)) + .setPositiveButton(R.string.ok, null) + .show() + } else { + activity.startActivity(openIntent) + } } } - fun removeFiles(context: Context) { - Thread{ - val success = ArrayList() - for (uri in storedFiles){ - if (context.contentResolver.delete(uri, null, null) == 1){ - success.add(uri) - } + fun removeFilesAsync(context: Context) = GlobalScope.launch(Dispatchers.IO) { + val success = HashSet(storedFiles.size) + for (uri in storedFiles) { + if (context.contentResolver.delete(uri, null, null) == 1) { + success.add(uri) } - for (uri in success){ - storedFiles.remove(uri) - } - }.start() + } + for (uri in success) { + storedFiles.remove(uri) + } } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt index 093a19d..0940be2 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt @@ -14,13 +14,16 @@ import android.widget.ImageButton import android.widget.TextView import android.widget.Toast import androidx.core.content.ContextCompat -import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import sushi.hardcore.droidfs.BaseActivity import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.ConstValues.isAudio @@ -312,21 +315,21 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene displayNumberOfElements(numberOfFilesText, R.string.one_file, R.string.multiple_files, explorerElements.count { it.isRegularFile }) displayNumberOfElements(numberOfFoldersText, R.string.one_folder, R.string.multiple_folders, explorerElements.count { it.isDirectory }) if (mapFolders) { - Thread { + lifecycleScope.launch { var totalSize: Long = 0 - synchronized(this) { - for (element in explorerElements) { - if (element.isDirectory) { - recursiveSetSize(element) + withContext(Dispatchers.IO) { + synchronized(this@BaseExplorerActivity) { + for (element in explorerElements) { + if (element.isDirectory) { + recursiveSetSize(element) + } + totalSize += element.size } - totalSize += element.size } } - runOnUiThread { - displayExplorerElements(totalSize) - onDisplayed?.invoke() - } - }.start() + displayExplorerElements(totalSize) + onDisplayed?.invoke() + } } else { displayExplorerElements(explorerElements.filter { !it.isParentFolder }.sumOf { it.size }) onDisplayed?.invoke() @@ -455,9 +458,12 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene if (items.size > 0) { checkPathOverwrite(items, currentDirectoryPath) { checkedItems -> checkedItems?.let { - fileOperationService.importFilesFromUris(checkedItems.map { it.dstPath!! }, uris){ failedItem -> - runOnUiThread { - callback(failedItem) + lifecycleScope.launch { + val taskResult = fileOperationService.importFilesFromUris(checkedItems.map { it.dstPath!! }, uris) + if (taskResult.cancelled) { + setCurrentPath(currentDirectoryPath) + } else { + callback(taskResult.failedItem) } } } @@ -465,20 +471,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene } } - fun importDirectory(sourceUri: Uri, callback: (String?, List, DocumentFile) -> Unit) { - val tree = DocumentFile.fromTreeUri(this, sourceUri)!! //non-null after Lollipop - val operation = OperationFile.fromExplorerElement(ExplorerElement(tree.name!!, 0, parentPath = currentDirectoryPath)) - checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation -> - checkedOperation?.let { - fileOperationService.importDirectory(checkedOperation[0].dstPath!!, tree) { failedItem, uris -> - runOnUiThread { - callback(failedItem, uris, tree) - } - } - } - } - } - protected fun rename(old_name: String, new_name: String){ if (new_name.isEmpty()) { Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show() @@ -627,7 +619,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene finish() } else { isStartingActivity = false - ExternalProvider.removeFiles(this) + ExternalProvider.removeFilesAsync(this) setCurrentPath(currentDirectoryPath) } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt index 0d49f3f..593fb4c 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt @@ -8,6 +8,8 @@ import android.view.MenuItem import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import sushi.hardcore.droidfs.CameraActivity import sushi.hardcore.droidfs.GocryptfsVolume import sushi.hardcore.droidfs.MainActivity @@ -67,19 +69,18 @@ class ExplorerActivity : BaseExplorerActivity() { if (items == null) { remoteGocryptfsVolume.close() } else { - fileOperationService.copyElements(items, remoteGocryptfsVolume){ failedItem -> - runOnUiThread { - if (failedItem == null){ - Toast.makeText(this, R.string.success_import, Toast.LENGTH_SHORT).show() - } else { - CustomAlertDialogBuilder(this, themeValue) - .setTitle(R.string.error) - .setMessage(getString(R.string.import_failed, failedItem)) - .setPositiveButton(R.string.ok, null) - .show() - } - setCurrentPath(currentDirectoryPath) + lifecycleScope.launch { + val failedItem = fileOperationService.copyElements(items, remoteGocryptfsVolume) + if (failedItem == null) { + Toast.makeText(this@ExplorerActivity, R.string.success_import, Toast.LENGTH_SHORT).show() + } else { + CustomAlertDialogBuilder(this@ExplorerActivity, themeValue) + .setTitle(R.string.error) + .setMessage(getString(R.string.import_failed, failedItem)) + .setPositiveButton(R.string.ok, null) + .show() } + setCurrentPath(currentDirectoryPath) remoteGocryptfsVolume.close() } } @@ -99,14 +100,15 @@ class ExplorerActivity : BaseExplorerActivity() { } private val pickExportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> if (uri != null) { - fileOperationService.exportFiles(uri, explorerAdapter.selectedItems.map { i -> explorerElements[i] }){ failedItem -> - runOnUiThread { - if (failedItem == null){ - Toast.makeText(this, R.string.success_export, Toast.LENGTH_SHORT).show() + lifecycleScope.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, themeValue) + CustomAlertDialogBuilder(this@ExplorerActivity, themeValue) .setTitle(R.string.error) - .setMessage(getString(R.string.export_failed, failedItem)) + .setMessage(getString(R.string.export_failed, result.failedItem)) .setPositiveButton(R.string.ok, null) .show() } @@ -117,7 +119,20 @@ class ExplorerActivity : BaseExplorerActivity() { } private val pickImportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { rootUri -> rootUri?.let { - importDirectory(it, ::onImportComplete) + val tree = DocumentFile.fromTreeUri(this, it)!! //non-null after Lollipop + val operation = OperationFile.fromExplorerElement(ExplorerElement(tree.name!!, 0, parentPath = currentDirectoryPath)) + checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation -> + checkedOperation?.let { + lifecycleScope.launch { + val result = fileOperationService.importDirectory(checkedOperation[0].dstPath!!, tree) + if (result.taskResult.cancelled) { + setCurrentPath(currentDirectoryPath) + } else { + onImportComplete(result.taskResult.failedItem, result.uris, tree) + } + } + } + } } } @@ -130,17 +145,16 @@ class ExplorerActivity : BaseExplorerActivity() { ${getString(R.string.ask_for_wipe)} """.trimIndent()) .setPositiveButton(R.string.yes) { _, _ -> - fileOperationService.wipeUris(urisToWipe, rootFile) { errorMsg -> - runOnUiThread { - if (errorMsg == null){ - Toast.makeText(this, R.string.wipe_successful, Toast.LENGTH_SHORT).show() - } else { - CustomAlertDialogBuilder(this, themeValue) - .setTitle(R.string.error) - .setMessage(getString(R.string.wipe_failed, errorMsg)) - .setPositiveButton(R.string.ok, null) - .show() - } + lifecycleScope.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, themeValue) + .setTitle(R.string.error) + .setMessage(getString(R.string.wipe_failed, errorMsg)) + .setPositiveButton(R.string.ok, null) + .show() } } } @@ -310,16 +324,17 @@ class ExplorerActivity : BaseExplorerActivity() { if (currentItemAction == ItemsActions.COPY){ checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items -> items?.let { - fileOperationService.copyElements(it.toMutableList() as ArrayList){ failedItem -> - runOnUiThread { - if (failedItem == null){ - Toast.makeText(this, R.string.copy_success, Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + val failedItem = fileOperationService.copyElements(it.toMutableList() as ArrayList) + if (!isFinishing) { + if (failedItem == null) { + Toast.makeText(this@ExplorerActivity, R.string.copy_success, Toast.LENGTH_SHORT).show() } else { - CustomAlertDialogBuilder(this, themeValue) - .setTitle(R.string.error) - .setMessage(getString(R.string.copy_failed, failedItem)) - .setPositiveButton(R.string.ok, null) - .show() + CustomAlertDialogBuilder(this@ExplorerActivity, themeValue) + .setTitle(R.string.error) + .setMessage(getString(R.string.copy_failed, failedItem)) + .setPositiveButton(R.string.ok, null) + .show() } setCurrentPath(currentDirectoryPath) } @@ -332,19 +347,18 @@ class ExplorerActivity : BaseExplorerActivity() { mapFileForMove(itemsToProcess, itemsToProcess[0].explorerElement.parentPath) checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items -> items?.let { - fileOperationService.moveElements(it.toMutableList() as ArrayList){ failedItem -> - runOnUiThread { - if (failedItem == null){ - Toast.makeText(this, R.string.move_success, Toast.LENGTH_SHORT).show() - } else { - CustomAlertDialogBuilder(this, themeValue) - .setTitle(R.string.error) - .setMessage(getString(R.string.move_failed, failedItem)) - .setPositiveButton(R.string.ok, null) - .show() - } - setCurrentPath(currentDirectoryPath) + lifecycleScope.launch { + val failedItem = fileOperationService.moveElements(it.toMutableList() as ArrayList) + if (failedItem == null) { + Toast.makeText(this@ExplorerActivity, R.string.move_success, Toast.LENGTH_SHORT).show() + } else { + CustomAlertDialogBuilder(this@ExplorerActivity, themeValue) + .setTitle(R.string.error) + .setMessage(getString(R.string.move_failed, failedItem)) + .setPositiveButton(R.string.ok, null) + .show() } + setCurrentPath(currentDirectoryPath) } } cancelItemAction() diff --git a/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationService.kt b/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationService.kt index deae407..1dddfa8 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationService.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationService.kt @@ -13,6 +13,7 @@ import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.* import sushi.hardcore.droidfs.GocryptfsVolume import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.explorers.ExplorerElement @@ -30,7 +31,7 @@ class FileOperationService : Service() { private val binder = LocalBinder() private lateinit var gocryptfsVolume: GocryptfsVolume private lateinit var notificationManager: NotificationManagerCompat - private var notifications = HashMap() + private val tasks = HashMap() private var lastNotificationId = 0 inner class LocalBinder : Binder() { @@ -88,7 +89,6 @@ class FileOperationService : Service() { .setContentText(getString(R.string.discovering_files)) .setProgress(0, 0, true) } - notifications[lastNotificationId] = false notificationManager.notify(lastNotificationId, notificationBuilder.build()) return FileOperationNotification(notificationBuilder, lastNotificationId) } @@ -105,7 +105,20 @@ class FileOperationService : Service() { } fun cancelOperation(notificationId: Int){ - notifications[notificationId] = true + tasks[notificationId]?.cancel() + } + + open class TaskResult(val cancelled: Boolean, val failedItem: T?) + + private suspend fun waitForTask(notification: FileOperationNotification, task: Deferred): TaskResult { + tasks[notification.notificationId] = task + return try { + TaskResult(false, task.await()) + } catch (e: CancellationException) { + TaskResult(true, null) + } finally { + cancelNotification(notification) + } } private fun copyFile(srcPath: String, dstPath: String, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume): Boolean { @@ -137,110 +150,105 @@ class FileOperationService : Service() { return success } - fun copyElements(items: ArrayList, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume, callback: (String?) -> Unit){ - Thread { - val notification = showNotification(R.string.file_op_copy_msg, items.size) + suspend fun copyElements( + items: ArrayList, + remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume + ): String? = coroutineScope { + val notification = showNotification(R.string.file_op_copy_msg, items.size) + val task = async { var failedItem: String? = null - for (i in 0 until items.size){ - if (notifications[notification.notificationId]!!){ - cancelNotification(notification) - return@Thread - } - if (items[i].explorerElement.isDirectory){ - if (!gocryptfsVolume.pathExists(items[i].dstPath!!)) { - if (!gocryptfsVolume.mkdir(items[i].dstPath!!)) { + for (i in 0 until items.size) { + withContext(Dispatchers.IO) { + if (items[i].explorerElement.isDirectory) { + if (!gocryptfsVolume.pathExists(items[i].dstPath!!)) { + if (!gocryptfsVolume.mkdir(items[i].dstPath!!)) { + failedItem = items[i].explorerElement.fullPath + } + } + } else { + if (!copyFile(items[i].explorerElement.fullPath, items[i].dstPath!!, remoteGocryptfsVolume)) { failedItem = items[i].explorerElement.fullPath } } - } else { - if (!copyFile(items[i].explorerElement.fullPath, items[i].dstPath!!, remoteGocryptfsVolume)){ - failedItem = items[i].explorerElement.fullPath - } } - if (failedItem == null){ - updateNotificationProgress(notification, i, items.size) + if (failedItem == null) { + updateNotificationProgress(notification, i+1, items.size) } else { break } } - cancelNotification(notification) - callback(failedItem) - }.start() + failedItem + } + // treat cancellation as success + waitForTask(notification, task).failedItem } - fun moveElements(items: ArrayList, callback: (String?) -> Unit){ - Thread { - val notification = showNotification(R.string.file_op_move_msg, items.size) - val mergedFolders = ArrayList() + suspend fun moveElements(items: ArrayList): String? = coroutineScope { + val notification = showNotification(R.string.file_op_move_msg, items.size) + val task = async { var failedItem: String? = null - for (i in 0 until items.size){ - if (notifications[notification.notificationId]!!){ - cancelNotification(notification) - return@Thread - } - if (items[i].explorerElement.isDirectory && gocryptfsVolume.pathExists(items[i].dstPath!!)){ //folder will be merged - mergedFolders.add(items[i].explorerElement.fullPath) - } else { - if (!gocryptfsVolume.rename(items[i].explorerElement.fullPath, items[i].dstPath!!)){ - failedItem = items[i].explorerElement.fullPath - break + withContext(Dispatchers.IO) { + val mergedFolders = ArrayList() + for (i in 0 until items.size) { + if (items[i].explorerElement.isDirectory && gocryptfsVolume.pathExists(items[i].dstPath!!)) { //folder will be merged + mergedFolders.add(items[i].explorerElement.fullPath) } else { - updateNotificationProgress(notification, i, items.size) + if (!gocryptfsVolume.rename(items[i].explorerElement.fullPath, items[i].dstPath!!)) { + failedItem = items[i].explorerElement.fullPath + break + } else { + updateNotificationProgress(notification, i+1, items.size) + } + } + } + if (failedItem == null) { + for (i in 0 until mergedFolders.size) { + if (!gocryptfsVolume.rmdir(mergedFolders[i])) { + failedItem = mergedFolders[i] + break + } else { + updateNotificationProgress(notification, items.size-(mergedFolders.size-i), items.size) + } } } } - if (failedItem == null){ - for (i in 0 until mergedFolders.size) { - if (notifications[notification.notificationId]!!){ - cancelNotification(notification) - return@Thread - } - if (!gocryptfsVolume.rmdir(mergedFolders[i])){ - failedItem = mergedFolders[i] - break - } else { - updateNotificationProgress(notification, items.size-(mergedFolders.size-i), items.size) - } - } - } - cancelNotification(notification) - callback(failedItem) - }.start() + failedItem + } + // treat cancellation as success + waitForTask(notification, task).failedItem } - private fun importFilesFromUris(dstPaths: List, uris: List, reuseNotification: FileOperationNotification? = null, callback: (String?) -> Unit){ - val notification = reuseNotification ?: showNotification(R.string.file_op_import_msg, dstPaths.size) + private suspend fun importFilesFromUris( + dstPaths: List, + uris: List, + notification: FileOperationNotification, + ): String? { var failedIndex = -1 for (i in dstPaths.indices) { - if (notifications[notification.notificationId]!!){ - cancelNotification(notification) - return - } - try { - if (!gocryptfsVolume.importFile(this, uris[i], dstPaths[i])) { + withContext(Dispatchers.IO) { + try { + if (!gocryptfsVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) { + failedIndex = i + } + } catch (e: FileNotFoundException) { failedIndex = i } - } catch (e: FileNotFoundException){ - failedIndex = i } if (failedIndex == -1) { - updateNotificationProgress(notification, i, dstPaths.size) + updateNotificationProgress(notification, i+1, dstPaths.size) } else { - cancelNotification(notification) - callback(uris[failedIndex].toString()) - break + return uris[failedIndex].toString() } } - if (failedIndex == -1){ - cancelNotification(notification) - callback(null) - } + return null } - fun importFilesFromUris(dstPaths: List, uris: List, callback: (String?) -> Unit) { - Thread { - importFilesFromUris(dstPaths, uris, null, callback) - }.start() + suspend fun importFilesFromUris(dstPaths: List, uris: List): TaskResult = coroutineScope { + val notification = showNotification(R.string.file_op_import_msg, dstPaths.size) + val task = async { + importFilesFromUris(dstPaths, uris, notification) + } + waitForTask(notification, task) } /** @@ -256,18 +264,17 @@ class FileOperationService : Service() { dstFiles: ArrayList, srcUris: ArrayList, dstDirs: ArrayList, - notification: FileOperationNotification + scope: CoroutineScope, ): Boolean { dstDirs.add(rootDstPath) for (child in rootSrcDir.listFiles()) { - if (notifications[notification.notificationId]!!) { - cancelNotification(notification) + if (!scope.isActive) { return false } child.name?.let { name -> val subPath = PathUtils.pathJoin(rootDstPath, name) if (child.isDirectory) { - if (!recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs, notification)) { + if (!recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs, scope)) { return false } } @@ -280,48 +287,50 @@ class FileOperationService : Service() { return true } - fun importDirectory(rootDstPath: String, rootSrcDir: DocumentFile, callback: (String?, List) -> Unit) { - Thread { - val notification = showNotification(R.string.file_op_import_msg, null) + class ImportDirectoryResult(val taskResult: TaskResult, val uris: List) + suspend fun importDirectory( + rootDstPath: String, + rootSrcDir: DocumentFile, + ): ImportDirectoryResult = coroutineScope { + val notification = showNotification(R.string.file_op_import_msg, null) + val srcUris = arrayListOf() + val task = async { + var failedItem: String? = null val dstFiles = arrayListOf() - val srcUris = arrayListOf() val dstDirs = arrayListOf() - if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, notification)) { - return@Thread - } - // create destination folders so the new files can use them - for (dir in dstDirs) { - if (notifications[notification.notificationId]!!) { - cancelNotification(notification) - return@Thread + withContext(Dispatchers.IO) { + if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, this)) { + return@withContext } - if (!gocryptfsVolume.mkdir(dir)) { - cancelNotification(notification) - callback(dir, srcUris) - break + + // create destination folders so the new files can use them + for (dir in dstDirs) { + if (!gocryptfsVolume.mkdir(dir)) { + failedItem = dir + break + } } } - - importFilesFromUris(dstFiles, srcUris, notification) { failedItem -> - callback(failedItem, srcUris) + if (failedItem == null) { + failedItem = importFilesFromUris(dstFiles, srcUris, notification) } - }.start() + failedItem + } + ImportDirectoryResult(waitForTask(notification, task), srcUris) } - fun wipeUris(uris: List, rootFile: DocumentFile? = null, callback: (String?) -> Unit){ - Thread { - val notification = showNotification(R.string.file_op_wiping_msg, uris.size) + suspend fun wipeUris(uris: List, rootFile: DocumentFile? = null): String? = coroutineScope { + val notification = showNotification(R.string.file_op_wiping_msg, uris.size) + val task = async { var errorMsg: String? = null for (i in uris.indices) { - if (notifications[notification.notificationId]!!){ - cancelNotification(notification) - return@Thread + withContext(Dispatchers.IO) { + errorMsg = Wiper.wipe(this@FileOperationService, uris[i]) } - errorMsg = Wiper.wipe(this, uris[i]) if (errorMsg == null) { - updateNotificationProgress(notification, i, uris.size) + updateNotificationProgress(notification, i+1, uris.size) } else { break } @@ -329,9 +338,10 @@ class FileOperationService : Service() { if (errorMsg == null) { rootFile?.delete() } - cancelNotification(notification) - callback(errorMsg) - }.start() + errorMsg + } + // treat cancellation as success + waitForTask(notification, task).failedItem } private fun exportFileInto(srcPath: String, treeDocumentFile: DocumentFile): Boolean { @@ -345,13 +355,20 @@ class FileOperationService : Service() { } } - private fun recursiveExportDirectory(plain_directory_path: String, treeDocumentFile: DocumentFile): String? { + private fun recursiveExportDirectory( + plain_directory_path: String, + treeDocumentFile: DocumentFile, + scope: CoroutineScope + ): String? { treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree -> val explorerElements = gocryptfsVolume.listDir(plain_directory_path) for (e in explorerElements) { + if (!scope.isActive) { + return null + } val fullPath = PathUtils.pathJoin(plain_directory_path, e.name) if (e.isDirectory) { - val failedItem = recursiveExportDirectory(fullPath, childTree) + val failedItem = recursiveExportDirectory(fullPath, childTree, scope) failedItem?.let { return it } } else { if (!exportFileInto(fullPath, childTree)){ @@ -364,40 +381,40 @@ class FileOperationService : Service() { return treeDocumentFile.name } - fun exportFiles(uri: Uri, items: List, callback: (String?) -> Unit){ - Thread { + suspend fun exportFiles(uri: Uri, items: List): TaskResult = coroutineScope { + val notification = showNotification(R.string.file_op_export_msg, items.size) + val task = async { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - DocumentFile.fromTreeUri(this, uri)?.let { treeDocumentFile -> - val notification = showNotification(R.string.file_op_export_msg, items.size) - var failedItem: String? = null - for (i in items.indices) { - if (notifications[notification.notificationId]!!){ - cancelNotification(notification) - return@Thread - } + 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) + recursiveExportDirectory(items[i].fullPath, treeDocumentFile, this) } else { if (exportFileInto(items[i].fullPath, treeDocumentFile)) null else items[i].fullPath } - if (failedItem == null) { - updateNotificationProgress(notification, i, items.size) - } else { - break - } } - cancelNotification(notification) - callback(failedItem) + if (failedItem == null) { + updateNotificationProgress(notification, i+1, items.size) + } else { + break + } } - }.start() + failedItem + } + waitForTask(notification, task) } - private fun recursiveCountChildElements(rootDirectory: DocumentFile): Int { + private fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int { + if (!scope.isActive) { + return 0 + } val children = rootDirectory.listFiles() var count = children.size for (child in children) { if (child.isDirectory) { - count += recursiveCountChildElements(child) + count += recursiveCountChildElements(child, scope) } } return count @@ -411,12 +428,13 @@ class FileOperationService : Service() { dstRootDirectory: ObjRef?, notification: FileOperationNotification, total: Int, + scope: CoroutineScope, progress: ObjRef = ObjRef(0) ): DocumentFile? { val dstDir = dst.createDirectory(src.name ?: return src) ?: return src + dstRootDirectory?.let { it.value = dstDir } for (child in src.listFiles()) { - if (notifications[notification.notificationId]!!) { - cancelNotification(notification) + if (!scope.isActive) { return null } if (child.isFile) { @@ -429,24 +447,29 @@ class FileOperationService : Service() { inputStream.close() if (written != child.length()) return child } else { - recursiveCopyVolume(child, dstDir, null, notification, total, progress)?.let { return it } + recursiveCopyVolume(child, dstDir, null, notification, total, scope, progress)?.let { return it } } progress.value++ updateNotificationProgress(notification, progress.value, total) } - dstRootDirectory?.let { it.value = dstDir } return null } - fun copyVolume(src: DocumentFile, dst: DocumentFile, callback: (DocumentFile?, DocumentFile?) -> Unit) { - Thread { - val notification = showNotification(R.string.copy_volume_notification, null) - val total = recursiveCountChildElements(src) - updateNotificationProgress(notification, 0, total) - val dstRootDirectory = ObjRef(null) - val failedItem = recursiveCopyVolume(src, dst, dstRootDirectory, notification, total) - cancelNotification(notification) - callback(dstRootDirectory.value, failedItem) - }.start() + class CopyVolumeResult(val taskResult: TaskResult, val dstRootDirectory: DocumentFile?) + + suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult = coroutineScope { + val notification = showNotification(R.string.copy_volume_notification, null) + val dstRootDirectory = ObjRef(null) + val task = async(Dispatchers.IO) { + val total = recursiveCountChildElements(src, this) + if (isActive) { + updateNotificationProgress(notification, 0, total) + recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this) + } else { + null + } + } + // treat cancellation as success + CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value) } } \ No newline at end of file