From e01b5a309842f60d464390185269e066fe879ef1 Mon Sep 17 00:00:00 2001 From: Hardcore Sushi Date: Sun, 6 Mar 2022 14:45:52 +0100 Subject: [PATCH] Volume copy --- .../sushi/hardcore/droidfs/MainActivity.kt | 137 ++++++++++++++++-- .../droidfs/add_volume/SelectPathFragment.kt | 21 +-- .../file_operations/FileOperationService.kt | 68 ++++++++- .../sushi/hardcore/droidfs/util/PathUtils.kt | 16 ++ app/src/main/res/menu/main_activity.xml | 5 + app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 6 +- 8 files changed, 223 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt index ecbb5ce..9892f51 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt @@ -1,15 +1,22 @@ package sushi.hardcore.droidfs import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.ServiceConnection +import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.IBinder import android.view.Menu import android.view.MenuItem 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.recyclerview.widget.LinearLayoutManager import sushi.hardcore.droidfs.adapters.VolumeAdapter import sushi.hardcore.droidfs.add_volume.AddVolumeActivity @@ -20,6 +27,7 @@ import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding import sushi.hardcore.droidfs.explorers.ExplorerActivity import sushi.hardcore.droidfs.explorers.ExplorerActivityDrop import sushi.hardcore.droidfs.explorers.ExplorerActivityPick +import sushi.hardcore.droidfs.file_operations.FileOperationService import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import java.io.File @@ -35,13 +43,7 @@ class MainActivity : BaseActivity() { private var usfKeepOpen: Boolean = false private var addVolume = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> when (result.resultCode) { - AddVolumeActivity.RESULT_VOLUME_ADDED -> { - volumeAdapter.apply { - volumes = volumeDatabase.getVolumes() - notifyItemInserted(volumes.size) - } - binding.textNoVolumes.visibility = View.GONE - } + AddVolumeActivity.RESULT_VOLUME_ADDED -> onVolumeAdded() AddVolumeActivity.RESULT_HASH_STORAGE_RESET -> { volumeAdapter.refresh() binding.textNoVolumes.visibility = View.GONE @@ -50,12 +52,13 @@ class MainActivity : BaseActivity() { } private var changePasswordPosition: Int? = null private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - changePasswordPosition?.let { - volumeAdapter.selectedItems.remove(it) - volumeAdapter.onVolumeChanged(it) - } - invalidateOptionsMenu() + changePasswordPosition?.let { unselect(it) } } + private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) + onDirectoryPicked(uri) + } + private lateinit var fileOperationService: FileOperationService private var pickMode = false private var dropMode = false private var shouldCloseVolume = true // used when launched to pick file from another volume @@ -107,6 +110,14 @@ class MainActivity : BaseActivity() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { fingerprintProtector = FingerprintProtector.new(this, themeValue, volumeDatabase) } + Intent(this, FileOperationService::class.java).also { + bindService(it, object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + fileOperationService = (service as FileOperationService.LocalBinder).getService() + } + override fun onServiceDisconnected(arg0: ComponentName) {} + }, Context.BIND_AUTO_CREATE) + } } private fun onVolumeItemClick(volume: Volume, position: Int) { @@ -120,11 +131,25 @@ class MainActivity : BaseActivity() { invalidateOptionsMenu() } + private fun onVolumeAdded() { + volumeAdapter.apply { + volumes = volumeDatabase.getVolumes() + notifyItemInserted(volumes.size) + } + binding.textNoVolumes.visibility = View.GONE + } + private fun unselectAll() { volumeAdapter.unSelectAll() invalidateOptionsMenu() } + private fun unselect(position: Int) { + volumeAdapter.selectedItems.remove(position) + volumeAdapter.onVolumeChanged(position) + invalidateOptionsMenu() + } + private fun removeVolumes(volumes: List, i: Int = 0, doDeleteVolumeContent: Boolean? = null) { if (i < volumes.size) { if (volumes[i].isHidden) { @@ -212,6 +237,32 @@ class MainActivity : BaseActivity() { }) true } + R.id.copy -> { + val position = volumeAdapter.selectedItems.elementAt(0) + val volume = volumeAdapter.volumes[position] + when { + volume.isHidden -> { + PathUtils.safePickDirectory(pickDirectory, this, themeValue) + } + File(filesDir, volume.shortName).exists() -> { + CustomAlertDialogBuilder(this, themeValue) + .setTitle(R.string.error) + .setMessage(R.string.hidden_volume_already_exists) + .setPositiveButton(R.string.ok, null) + .show() + } + else -> { + unselect(position) + copyVolume( + DocumentFile.fromFile(File(volume.name)), + DocumentFile.fromFile(filesDir), + ) { + Volume(volume.shortName, true, volume.encryptedHash, volume.iv) + } + } + } + true + } R.id.settings -> { val intent = Intent(this, SettingsActivity::class.java) startActivity(intent) @@ -241,10 +292,72 @@ class MainActivity : BaseActivity() { !pickMode && !dropMode && volumeAdapter.selectedItems.size == 1 && volumeAdapter.volumes[volumeAdapter.selectedItems.elementAt(0)].canWrite(filesDir.path) + with(menu.findItem(R.id.copy)) { + isVisible = !pickMode && !dropMode && volumeAdapter.selectedItems.size == 1 + if (isVisible) { + setTitle(if (volumeAdapter.volumes[volumeAdapter.selectedItems.elementAt(0)].isHidden) + R.string.copy_hidden_volume + else + R.string.copy_external_volume + ) + } + } supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || pickMode || dropMode) return true } + private fun onDirectoryPicked(uri: Uri) { + val position = volumeAdapter.selectedItems.elementAt(0) + val volume = volumeAdapter.volumes[position] + unselect(position) + val dstDocumentFile = DocumentFile.fromTreeUri(this, uri) + if (dstDocumentFile == null) { + CustomAlertDialogBuilder(this, themeValue) + .setTitle(R.string.error) + .setMessage(R.string.path_error) + .setPositiveButton(R.string.ok, null) + .show() + } else { + copyVolume( + DocumentFile.fromFile(File(volume.getFullPath(filesDir.path))), + dstDocumentFile, + ) { dstRootDirectory -> + dstRootDirectory.name?.let { name -> + val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this) + if (path == null) null + else Volume( + PathUtils.pathJoin(path, name), + false, + volume.encryptedHash, + volume.iv + ) + } + } + } + } + + private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> Volume?) { + fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile) { dstRootDirectory, failedItem -> + runOnUiThread { + if (failedItem == null) { + dstRootDirectory?.let { + getResultVolume(it)?.let { volume -> + volumeDatabase.saveVolume(volume) + onVolumeAdded() + } + } + Toast.makeText(this, R.string.copy_success, Toast.LENGTH_SHORT).show() + } else { + CustomAlertDialogBuilder(this, themeValue) + .setTitle(R.string.error) + .setMessage(getString(R.string.copy_failed, failedItem.name)) + .setPositiveButton(R.string.ok, null) + .show() + } + } + } + } + @SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23 private fun openVolume(volume: Volume, position: Int) { var askForPassword = true diff --git a/app/src/main/java/sushi/hardcore/droidfs/add_volume/SelectPathFragment.kt b/app/src/main/java/sushi/hardcore/droidfs/add_volume/SelectPathFragment.kt index 022245a..9cd49f2 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/add_volume/SelectPathFragment.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/add_volume/SelectPathFragment.kt @@ -2,7 +2,6 @@ package sushi.hardcore.droidfs.add_volume import android.Manifest import android.annotation.SuppressLint -import android.content.ActivityNotFoundException import android.content.pm.PackageManager import android.net.Uri import android.os.Build @@ -40,7 +39,7 @@ class SelectPathFragment: Fragment() { private lateinit var binding: FragmentSelectPathBinding private val askStoragePermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> if (result[Manifest.permission.READ_EXTERNAL_STORAGE] == true && result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) - safePickDirectory() + PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue) else CustomAlertDialogBuilder(requireContext(), themeValue) .setTitle(R.string.storage_perm_denied) @@ -87,7 +86,7 @@ class SelectPathFragment: Fragment() { Manifest.permission.WRITE_EXTERNAL_STORAGE ) == PackageManager.PERMISSION_GRANTED ) - safePickDirectory() + PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue) else askStoragePermissions.launch( arrayOf( @@ -96,7 +95,7 @@ class SelectPathFragment: Fragment() { ) ) } else - safePickDirectory() + PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue) } var isVolumeAlreadySaved = false var volumeAction: Action? = null @@ -145,18 +144,6 @@ class SelectPathFragment: Fragment() { } } - private fun safePickDirectory() { - try { - pickDirectory.launch(null) - } catch (e: ActivityNotFoundException) { - CustomAlertDialogBuilder(requireContext(), themeValue) - .setTitle(R.string.error) - .setMessage(R.string.open_tree_failed) - .setPositiveButton(R.string.ok, null) - .show() - } - } - private fun onDirectoryPicked(uri: Uri) { val path = PathUtils.getFullPathFromTreeUri(uri, requireContext()) if (path != null) @@ -164,7 +151,7 @@ class SelectPathFragment: Fragment() { else CustomAlertDialogBuilder(requireContext(), themeValue) .setTitle(R.string.error) - .setMessage(R.string.path_from_uri_null_error_msg) + .setMessage(R.string.path_error) .setPositiveButton(R.string.ok, null) .show() } 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 c34bbdb..deae407 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 @@ -1,9 +1,15 @@ package sushi.hardcore.droidfs.file_operations -import android.app.* +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service import android.content.Intent import android.net.Uri -import android.os.* +import android.os.Binder +import android.os.Build +import android.os.Bundle +import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.documentfile.provider.DocumentFile @@ -385,4 +391,62 @@ class FileOperationService : Service() { } }.start() } + + private fun recursiveCountChildElements(rootDirectory: DocumentFile): Int { + val children = rootDirectory.listFiles() + var count = children.size + for (child in children) { + if (child.isDirectory) { + count += recursiveCountChildElements(child) + } + } + return count + } + + internal class ObjRef(var value: T) + + private fun recursiveCopyVolume( + src: DocumentFile, + dst: DocumentFile, + dstRootDirectory: ObjRef?, + notification: FileOperationNotification, + total: Int, + progress: ObjRef = ObjRef(0) + ): DocumentFile? { + val dstDir = dst.createDirectory(src.name ?: return src) ?: return src + for (child in src.listFiles()) { + if (notifications[notification.notificationId]!!) { + cancelNotification(notification) + return null + } + if (child.isFile) { + val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child + val outputStream = contentResolver.openOutputStream(dstFile.uri) + val inputStream = contentResolver.openInputStream(child.uri) + if (outputStream == null || inputStream == null) return child + val written = inputStream.copyTo(outputStream) + outputStream.close() + inputStream.close() + if (written != child.length()) return child + } else { + recursiveCopyVolume(child, dstDir, null, notification, total, 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() + } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/util/PathUtils.kt b/app/src/main/java/sushi/hardcore/droidfs/util/PathUtils.kt index 4379fa6..0892d3e 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/util/PathUtils.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/util/PathUtils.kt @@ -1,11 +1,15 @@ package sushi.hardcore.droidfs.util +import android.content.ActivityNotFoundException import android.content.Context import android.net.Uri import android.os.storage.StorageManager import android.provider.DocumentsContract import android.provider.OpenableColumns +import androidx.activity.result.ActivityResultLauncher import androidx.core.content.ContextCompat +import sushi.hardcore.droidfs.R +import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import java.io.File import java.text.DecimalFormat import kotlin.math.log10 @@ -187,4 +191,16 @@ object PathUtils { } return rootDirectory.delete() } + + fun safePickDirectory(directoryPicker: ActivityResultLauncher, context: Context, themeValue: String) { + try { + directoryPicker.launch(null) + } catch (e: ActivityNotFoundException) { + CustomAlertDialogBuilder(context, themeValue) + .setTitle(R.string.error) + .setMessage(R.string.open_tree_failed) + .setPositiveButton(R.string.ok, null) + .show() + } + } } \ No newline at end of file diff --git a/app/src/main/res/menu/main_activity.xml b/app/src/main/res/menu/main_activity.xml index 7de86d7..dc97a65 100644 --- a/app/src/main/res/menu/main_activity.xml +++ b/app/src/main/res/menu/main_activity.xml @@ -34,4 +34,9 @@ android:visible="false" android:title="@string/change_password"/> + + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3649ed0..e83f8b3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -128,7 +128,7 @@ Transferência bem sucedida! Definir a duração do tempo (em s) Por favor, digite um valor numérico - Falha ao carregar a localização selecionada. + Falha ao carregar a localização selecionada. DroidFS não pode salvar neste local. Por favor, tente algum outro. DroidFS só pode escrever em memórias SD removíveis sob: Apresentação parou diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e21185a..93d89fa 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -123,7 +123,7 @@ Перемещение выполнено! Введите время таймера (в сек.) Введите числовое значение. - Невозможно получить выбранный путь. + Невозможно получить выбранный путь. DroidFS не имеет доступа на запись по этому пути. Попробуйте найти другое место. DroidFS может записывать на SD-карты только в: Слайдшоу остановлено diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9b8bd2..24c638c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,7 +128,7 @@ Move successful ! Enter the timer duration (in s) Please enter a numeric value - Failed to retrieve the selected path. + Failed to retrieve the selected path. DroidFS doesn\'t have write access to this path. Please try another location. Adding volume with read-only access. DroidFS doesn\'t have write access to this path. Adding volume with read-only access. @@ -222,4 +222,8 @@ New password Repeat the new password: This feature is only available on Android 6.0 (Marshmallow) or above. + Copy to shared storage + Make a hidden copy + Copying volume… + A hidden volume with the same name already exists.