Browse Source

Switch to Kotlin coroutines

cryfs
Hardcore Sushi 3 months ago
parent
commit
e00abdf5bb
Signed by: hardcoresushi
GPG Key ID: AFE384344A45E13A
  1. 17
      app/src/main/java/sushi/hardcore/droidfs/CameraActivity.kt
  2. 97
      app/src/main/java/sushi/hardcore/droidfs/ChangePasswordActivity.kt
  3. 47
      app/src/main/java/sushi/hardcore/droidfs/LoadingTask.kt
  4. 134
      app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt
  5. 32
      app/src/main/java/sushi/hardcore/droidfs/adapters/ExplorerElementAdapter.kt
  6. 94
      app/src/main/java/sushi/hardcore/droidfs/add_volume/CreateVolumeFragment.kt
  7. 87
      app/src/main/java/sushi/hardcore/droidfs/content_providers/ExternalProvider.kt
  8. 52
      app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt
  9. 116
      app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt
  10. 319
      app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationService.kt

17
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()
}

97
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<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 {
@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()
}
}
}

47
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<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){
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)
}
}
}

134
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<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)
.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<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())
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()
}
}
}

32
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<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)
}
}

94
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<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,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() {

87
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<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>()
var contentType: String? = null
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,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<Intent?>(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<Uri>()
for (uri in storedFiles){
if (context.contentResolver.delete(uri, null, null) == 1){
success.add(uri)
}
}
for (uri in success){
storedFiles.remove(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)
}
}.start()
}
for (uri in success) {
storedFiles.remove(uri)
}
}
}

52
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<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)
}
}

116
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<OperationFile>){ 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<OperationFile>)
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<OperationFile>){ 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<OperationFile>)
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()

319
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<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,110 +150,105 @@ class FileOperationService : Service() {
return success
}
fun copyElements(items: ArrayList<OperationFile>, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume, callback: (String?) -> Unit){
Thread {
val notification = showNotification(R.string.file_op_copy_msg, items.size)
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
}
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<OperationFile>, callback: (String?) -> Unit){
Thread {
val notification = showNotification(R.string.file_op_move_msg, items.size)
val mergedFolders = ArrayList<String>()
suspend fun moveElements(items: ArrayList<OperationFile>): 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<String>()
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 (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)
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)
}
}
}
}
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
}
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){