Switch to Kotlin coroutines

This commit is contained in:
Matéo Duparc 2022-04-20 15:17:33 +02:00
parent 72cce1d7e1
commit e00abdf5bb
Signed by untrusted user: hardcoresushi
GPG Key ID: AFE384344A45E13A
10 changed files with 528 additions and 471 deletions

View File

@ -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
}
}
isWaitingForTimer = false
}.start()
}
} else {
action()
}

View File

@ -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,8 +105,6 @@ 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)
@ -116,11 +114,23 @@ class ChangePasswordActivity: BaseActivity() {
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)) {
object : LoadingTask<Boolean>(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 {
}
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 {
@ -146,22 +156,13 @@ class ChangePasswordActivity: BaseActivity() {
} else {
finish()
}
}
} else {
stopTask {
CustomAlertDialogBuilder(activity, themeValue)
CustomAlertDialogBuilder(this, 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)
}
}
}
}

View File

@ -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<T>(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<TextView>(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){
scope.launch {
val result = withContext(Dispatchers.IO) {
doTask()
}
dialogLoading.dismiss()
}
}.start()
}
fun stopTask(onUiThread: (() -> Unit)?){
isStopped = true
dialogLoading.dismiss()
onUiThread?.let {
activity.runOnUiThread {
onUiThread()
}
onDone(result)
}
}
}

View File

@ -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,15 +463,17 @@ 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<Int>(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)
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)
@ -474,7 +481,6 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
}
}
}
override fun onPasswordHashSaved() {}
override fun onFailed(pending: Boolean) {
if (!pending) {
@ -544,19 +550,21 @@ 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)
}
object : LoadingTask<Int>(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())
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)
stopTask {
if (savePasswordHash && returnedHash != null && fingerprintProtector != null) {
fingerprintProtector.listener = object : FingerprintProtector.Listener {
override fun onHashStorageReset() {
volumeAdapter.refresh()
@ -577,12 +585,11 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
}
fingerprintProtector.savePasswordHash(volume, returnedHash)
} else {
startExplorer(sessionId, volume.shortName)
}
else
stopTask { startExplorer(sessionId, volume.shortName) }
} else
stopTask {
CustomAlertDialogBuilder(activity, themeValue)
} else {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.open_volume_failed)
.setMessage(R.string.open_volume_failed_msg)
.setPositiveButton(R.string.ok, null)
@ -593,7 +600,6 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
}
}
}
private fun startExplorer(sessionId: Int, volumeShortName: String) {
var explorerIntent: Intent? = null

View File

@ -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<in Drawable>?
) {
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)
}
}

View File

@ -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<Volume?>(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,7 +157,21 @@ class CreateVolumeFragment: Fragment() {
removeVolume(volumeName)
saveVolume(volume)
}
stopTask {
volume
} else {
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 {
@ -171,20 +197,8 @@ class CreateVolumeFragment: Fragment() {
}
} else onVolumeCreated()
}
} else {
stopTask {
CustomAlertDialogBuilder(activity, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.create_volume_failed)
.setPositiveButton(R.string.ok, null)
.show()
}
}
Arrays.fill(password, 0.toChar())
}
}
}
Arrays.fill(passwordConfirm, 0.toChar())
}
private fun onVolumeCreated() {

View File

@ -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<Uri> = ArrayList()
private var storedFiles = HashSet<Uri>()
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<String>) {
object : LoadingTask(activity, themeValue, R.string.loading_msg_export) {
override fun doTask(activity: AppCompatActivity) {
var contentType: String? = null
val uris = ArrayList<Uri>()
val uris = ArrayList<Uri>(file_paths.size)
object : LoadingTask<String?>(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,37 +72,44 @@ 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)))
}
}
}
}
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) {
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))
.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<Intent?>(activity, themeValue, R.string.loading_msg_export) {
override suspend fun doTask(): Intent? {
val result = exportFile(activity, gocryptfsVolume, file_path, null)
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<Uri>()
fun removeFilesAsync(context: Context) = GlobalScope.launch(Dispatchers.IO) {
val success = HashSet<Uri>(storedFiles.size)
for (uri in storedFiles) {
if (context.contentResolver.delete(uri, null, null) == 1) {
success.add(uri)
@ -110,6 +118,5 @@ object ExternalProvider {
for (uri in success) {
storedFiles.remove(uri)
}
}.start()
}
}

View File

@ -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,9 +315,10 @@ 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) {
withContext(Dispatchers.IO) {
synchronized(this@BaseExplorerActivity) {
for (element in explorerElements) {
if (element.isDirectory) {
recursiveSetSize(element)
@ -322,11 +326,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
totalSize += element.size
}
}
runOnUiThread {
}
displayExplorerElements(totalSize)
onDisplayed?.invoke()
}
}.start()
} 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<Uri>, 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)
}
}

View File

@ -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 {
lifecycleScope.launch {
val failedItem = fileOperationService.copyElements(items, remoteGocryptfsVolume)
if (failedItem == null) {
Toast.makeText(this, R.string.success_import, Toast.LENGTH_SHORT).show()
Toast.makeText(this@ExplorerActivity, R.string.success_import, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this, themeValue)
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,12 +145,12 @@ class ExplorerActivity : BaseExplorerActivity() {
${getString(R.string.ask_for_wipe)}
""".trimIndent())
.setPositiveButton(R.string.yes) { _, _ ->
fileOperationService.wipeUris(urisToWipe, rootFile) { errorMsg ->
runOnUiThread {
lifecycleScope.launch {
val errorMsg = fileOperationService.wipeUris(urisToWipe, rootFile)
if (errorMsg == null) {
Toast.makeText(this, R.string.wipe_successful, Toast.LENGTH_SHORT).show()
Toast.makeText(this@ExplorerActivity, R.string.wipe_successful, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this, themeValue)
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.wipe_failed, errorMsg))
.setPositiveButton(R.string.ok, null)
@ -143,7 +158,6 @@ class ExplorerActivity : BaseExplorerActivity() {
}
}
}
}
.setNegativeButton(R.string.no, null)
.show()
} else {
@ -310,12 +324,13 @@ class ExplorerActivity : BaseExplorerActivity() {
if (currentItemAction == ItemsActions.COPY){
checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items ->
items?.let {
fileOperationService.copyElements(it.toMutableList() as ArrayList<OperationFile>){ failedItem ->
runOnUiThread {
lifecycleScope.launch {
val failedItem = fileOperationService.copyElements(it.toMutableList() as ArrayList<OperationFile>)
if (!isFinishing) {
if (failedItem == null) {
Toast.makeText(this, R.string.copy_success, Toast.LENGTH_SHORT).show()
Toast.makeText(this@ExplorerActivity, R.string.copy_success, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this, themeValue)
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, failedItem))
.setPositiveButton(R.string.ok, null)
@ -332,12 +347,12 @@ class ExplorerActivity : BaseExplorerActivity() {
mapFileForMove(itemsToProcess, itemsToProcess[0].explorerElement.parentPath)
checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items ->
items?.let {
fileOperationService.moveElements(it.toMutableList() as ArrayList<OperationFile>){ failedItem ->
runOnUiThread {
lifecycleScope.launch {
val failedItem = fileOperationService.moveElements(it.toMutableList() as ArrayList<OperationFile>)
if (failedItem == null) {
Toast.makeText(this, R.string.move_success, Toast.LENGTH_SHORT).show()
Toast.makeText(this@ExplorerActivity, R.string.move_success, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this, themeValue)
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.move_failed, failedItem))
.setPositiveButton(R.string.ok, null)
@ -346,7 +361,6 @@ class ExplorerActivity : BaseExplorerActivity() {
setCurrentPath(currentDirectoryPath)
}
}
}
cancelItemAction()
invalidateOptionsMenu()
}

View File

@ -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<Int, Boolean>()
private val tasks = HashMap<Int, Job>()
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<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 copyFile(srcPath: String, dstPath: String, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume): Boolean {
@ -137,15 +150,15 @@ class FileOperationService : Service() {
return success
}
fun copyElements(items: ArrayList<OperationFile>, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume, callback: (String?) -> Unit){
Thread {
suspend fun copyElements(
items: ArrayList<OperationFile>,
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
}
withContext(Dispatchers.IO) {
if (items[i].explorerElement.isDirectory) {
if (!gocryptfsVolume.pathExists(items[i].dstPath!!)) {
if (!gocryptfsVolume.mkdir(items[i].dstPath!!)) {
@ -157,27 +170,26 @@ class FileOperationService : Service() {
failedItem = items[i].explorerElement.fullPath
}
}
}
if (failedItem == null) {
updateNotificationProgress(notification, i, items.size)
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<OperationFile>, callback: (String?) -> Unit){
Thread {
suspend fun moveElements(items: ArrayList<OperationFile>): String? = coroutineScope {
val notification = showNotification(R.string.file_op_move_msg, items.size)
val mergedFolders = ArrayList<String>()
val task = async {
var failedItem: String? = null
withContext(Dispatchers.IO) {
val mergedFolders = ArrayList<String>()
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 {
@ -185,16 +197,12 @@ class FileOperationService : Service() {
failedItem = items[i].explorerElement.fullPath
break
} else {
updateNotificationProgress(notification, i, items.size)
updateNotificationProgress(notification, i+1, 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
@ -203,44 +211,44 @@ class FileOperationService : Service() {
}
}
}
cancelNotification(notification)
callback(failedItem)
}.start()
}
failedItem
}
// treat cancellation as success
waitForTask(notification, task).failedItem
}
private fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>, reuseNotification: FileOperationNotification? = null, callback: (String?) -> Unit){
val notification = reuseNotification ?: showNotification(R.string.file_op_import_msg, dstPaths.size)
private suspend fun importFilesFromUris(
dstPaths: List<String>,
uris: List<Uri>,
notification: FileOperationNotification,
): String? {
var failedIndex = -1
for (i in dstPaths.indices) {
if (notifications[notification.notificationId]!!){
cancelNotification(notification)
return
}
withContext(Dispatchers.IO) {
try {
if (!gocryptfsVolume.importFile(this, uris[i], dstPaths[i])) {
if (!gocryptfsVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
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<String>, uris: List<Uri>, callback: (String?) -> Unit) {
Thread {
importFilesFromUris(dstPaths, uris, null, callback)
}.start()
suspend fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>): TaskResult<String?> = 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<String>,
srcUris: ArrayList<Uri>,
dstDirs: ArrayList<String>,
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<Uri>) -> Unit) {
Thread {
val notification = showNotification(R.string.file_op_import_msg, null)
class ImportDirectoryResult(val taskResult: TaskResult<String?>, val uris: List<Uri>)
val dstFiles = arrayListOf<String>()
suspend fun importDirectory(
rootDstPath: String,
rootSrcDir: DocumentFile,
): ImportDirectoryResult = coroutineScope {
val notification = showNotification(R.string.file_op_import_msg, null)
val srcUris = arrayListOf<Uri>()
val task = async {
var failedItem: String? = null
val dstFiles = arrayListOf<String>()
val dstDirs = arrayListOf<String>()
if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, notification)) {
return@Thread
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 (notifications[notification.notificationId]!!) {
cancelNotification(notification)
return@Thread
}
if (!gocryptfsVolume.mkdir(dir)) {
cancelNotification(notification)
callback(dir, srcUris)
failedItem = dir
break
}
}
importFilesFromUris(dstFiles, srcUris, notification) { failedItem ->
callback(failedItem, srcUris)
}
}.start()
if (failedItem == null) {
failedItem = importFilesFromUris(dstFiles, srcUris, notification)
}
failedItem
}
ImportDirectoryResult(waitForTask(notification, task), srcUris)
}
fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null, callback: (String?) -> Unit){
Thread {
suspend fun wipeUris(uris: List<Uri>, 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<ExplorerElement>, callback: (String?) -> Unit){
Thread {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
DocumentFile.fromTreeUri(this, uri)?.let { treeDocumentFile ->
suspend fun exportFiles(uri: Uri, items: List<ExplorerElement>): TaskResult<String?> = coroutineScope {
val notification = showNotification(R.string.file_op_export_msg, items.size)
val task = async {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
var failedItem: String? = null
for (i in items.indices) {
if (notifications[notification.notificationId]!!){
cancelNotification(notification)
return@Thread
}
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)
updateNotificationProgress(notification, i+1, items.size)
} else {
break
}
}
cancelNotification(notification)
callback(failedItem)
failedItem
}
}.start()
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<DocumentFile?>?,
notification: FileOperationNotification,
total: Int,
scope: CoroutineScope,
progress: ObjRef<Int> = 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 {
class CopyVolumeResult(val taskResult: TaskResult<DocumentFile?>, val dstRootDirectory: DocumentFile?)
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult = coroutineScope {
val notification = showNotification(R.string.copy_volume_notification, null)
val total = recursiveCountChildElements(src)
updateNotificationProgress(notification, 0, total)
val dstRootDirectory = ObjRef<DocumentFile?>(null)
val failedItem = recursiveCopyVolume(src, dst, dstRootDirectory, notification, total)
cancelNotification(notification)
callback(dstRootDirectory.value, failedItem)
}.start()
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)
}
}