Volume copy

This commit is contained in:
Matéo Duparc 2022-03-06 14:45:52 +01:00
parent bea0906f65
commit e01b5a3098
Signed by: hardcoresushi
GPG Key ID: AFE384344A45E13A
8 changed files with 223 additions and 34 deletions

View File

@ -1,15 +1,22 @@
package sushi.hardcore.droidfs package sushi.hardcore.droidfs
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.documentfile.provider.DocumentFile
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import sushi.hardcore.droidfs.adapters.VolumeAdapter import sushi.hardcore.droidfs.adapters.VolumeAdapter
import sushi.hardcore.droidfs.add_volume.AddVolumeActivity 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.ExplorerActivity
import sushi.hardcore.droidfs.explorers.ExplorerActivityDrop import sushi.hardcore.droidfs.explorers.ExplorerActivityDrop
import sushi.hardcore.droidfs.explorers.ExplorerActivityPick import sushi.hardcore.droidfs.explorers.ExplorerActivityPick
import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import java.io.File
@ -35,13 +43,7 @@ class MainActivity : BaseActivity() {
private var usfKeepOpen: Boolean = false private var usfKeepOpen: Boolean = false
private var addVolume = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private var addVolume = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
when (result.resultCode) { when (result.resultCode) {
AddVolumeActivity.RESULT_VOLUME_ADDED -> { AddVolumeActivity.RESULT_VOLUME_ADDED -> onVolumeAdded()
volumeAdapter.apply {
volumes = volumeDatabase.getVolumes()
notifyItemInserted(volumes.size)
}
binding.textNoVolumes.visibility = View.GONE
}
AddVolumeActivity.RESULT_HASH_STORAGE_RESET -> { AddVolumeActivity.RESULT_HASH_STORAGE_RESET -> {
volumeAdapter.refresh() volumeAdapter.refresh()
binding.textNoVolumes.visibility = View.GONE binding.textNoVolumes.visibility = View.GONE
@ -50,12 +52,13 @@ class MainActivity : BaseActivity() {
} }
private var changePasswordPosition: Int? = null private var changePasswordPosition: Int? = null
private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
changePasswordPosition?.let { changePasswordPosition?.let { unselect(it) }
volumeAdapter.selectedItems.remove(it)
volumeAdapter.onVolumeChanged(it)
} }
invalidateOptionsMenu() private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null)
onDirectoryPicked(uri)
} }
private lateinit var fileOperationService: FileOperationService
private var pickMode = false private var pickMode = false
private var dropMode = false private var dropMode = false
private var shouldCloseVolume = true // used when launched to pick file from another volume 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
fingerprintProtector = FingerprintProtector.new(this, themeValue, volumeDatabase) 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) { private fun onVolumeItemClick(volume: Volume, position: Int) {
@ -120,11 +131,25 @@ class MainActivity : BaseActivity() {
invalidateOptionsMenu() invalidateOptionsMenu()
} }
private fun onVolumeAdded() {
volumeAdapter.apply {
volumes = volumeDatabase.getVolumes()
notifyItemInserted(volumes.size)
}
binding.textNoVolumes.visibility = View.GONE
}
private fun unselectAll() { private fun unselectAll() {
volumeAdapter.unSelectAll() volumeAdapter.unSelectAll()
invalidateOptionsMenu() invalidateOptionsMenu()
} }
private fun unselect(position: Int) {
volumeAdapter.selectedItems.remove(position)
volumeAdapter.onVolumeChanged(position)
invalidateOptionsMenu()
}
private fun removeVolumes(volumes: List<Volume>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) { private fun removeVolumes(volumes: List<Volume>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
if (i < volumes.size) { if (i < volumes.size) {
if (volumes[i].isHidden) { if (volumes[i].isHidden) {
@ -212,6 +237,32 @@ class MainActivity : BaseActivity() {
}) })
true 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 -> { R.id.settings -> {
val intent = Intent(this, SettingsActivity::class.java) val intent = Intent(this, SettingsActivity::class.java)
startActivity(intent) startActivity(intent)
@ -241,10 +292,72 @@ class MainActivity : BaseActivity() {
!pickMode && !dropMode && !pickMode && !dropMode &&
volumeAdapter.selectedItems.size == 1 && volumeAdapter.selectedItems.size == 1 &&
volumeAdapter.volumes[volumeAdapter.selectedItems.elementAt(0)].canWrite(filesDir.path) 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) supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || pickMode || dropMode)
return true 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 @SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23
private fun openVolume(volume: Volume, position: Int) { private fun openVolume(volume: Volume, position: Int) {
var askForPassword = true var askForPassword = true

View File

@ -2,7 +2,6 @@ package sushi.hardcore.droidfs.add_volume
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -40,7 +39,7 @@ class SelectPathFragment: Fragment() {
private lateinit var binding: FragmentSelectPathBinding private lateinit var binding: FragmentSelectPathBinding
private val askStoragePermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> private val askStoragePermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
if (result[Manifest.permission.READ_EXTERNAL_STORAGE] == true && result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) if (result[Manifest.permission.READ_EXTERNAL_STORAGE] == true && result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true)
safePickDirectory() PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue)
else else
CustomAlertDialogBuilder(requireContext(), themeValue) CustomAlertDialogBuilder(requireContext(), themeValue)
.setTitle(R.string.storage_perm_denied) .setTitle(R.string.storage_perm_denied)
@ -87,7 +86,7 @@ class SelectPathFragment: Fragment() {
Manifest.permission.WRITE_EXTERNAL_STORAGE Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
) )
safePickDirectory() PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue)
else else
askStoragePermissions.launch( askStoragePermissions.launch(
arrayOf( arrayOf(
@ -96,7 +95,7 @@ class SelectPathFragment: Fragment() {
) )
) )
} else } else
safePickDirectory() PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue)
} }
var isVolumeAlreadySaved = false var isVolumeAlreadySaved = false
var volumeAction: Action? = null 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) { private fun onDirectoryPicked(uri: Uri) {
val path = PathUtils.getFullPathFromTreeUri(uri, requireContext()) val path = PathUtils.getFullPathFromTreeUri(uri, requireContext())
if (path != null) if (path != null)
@ -164,7 +151,7 @@ class SelectPathFragment: Fragment() {
else else
CustomAlertDialogBuilder(requireContext(), themeValue) CustomAlertDialogBuilder(requireContext(), themeValue)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.path_from_uri_null_error_msg) .setMessage(R.string.path_error)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} }

View File

@ -1,9 +1,15 @@
package sushi.hardcore.droidfs.file_operations 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.content.Intent
import android.net.Uri 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.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@ -385,4 +391,62 @@ class FileOperationService : Service() {
} }
}.start() }.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<T>(var value: T)
private fun recursiveCopyVolume(
src: DocumentFile,
dst: DocumentFile,
dstRootDirectory: ObjRef<DocumentFile?>?,
notification: FileOperationNotification,
total: Int,
progress: ObjRef<Int> = 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<DocumentFile?>(null)
val failedItem = recursiveCopyVolume(src, dst, dstRootDirectory, notification, total)
cancelNotification(notification)
callback(dstRootDirectory.value, failedItem)
}.start()
}
} }

View File

@ -1,11 +1,15 @@
package sushi.hardcore.droidfs.util package sushi.hardcore.droidfs.util
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.activity.result.ActivityResultLauncher
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import java.io.File
import java.text.DecimalFormat import java.text.DecimalFormat
import kotlin.math.log10 import kotlin.math.log10
@ -187,4 +191,16 @@ object PathUtils {
} }
return rootDirectory.delete() return rootDirectory.delete()
} }
fun safePickDirectory(directoryPicker: ActivityResultLauncher<Uri>, 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()
}
}
} }

View File

@ -34,4 +34,9 @@
android:visible="false" android:visible="false"
android:title="@string/change_password"/> android:title="@string/change_password"/>
<item
android:id="@+id/copy"
app:showAsAction="never"
android:visible="false"/>
</menu> </menu>

View File

@ -128,7 +128,7 @@
<string name="move_success">Transferência bem sucedida!</string> <string name="move_success">Transferência bem sucedida!</string>
<string name="enter_timer_duration">Definir a duração do tempo (em s)</string> <string name="enter_timer_duration">Definir a duração do tempo (em s)</string>
<string name="timer_empty_error_msg">Por favor, digite um valor numérico</string> <string name="timer_empty_error_msg">Por favor, digite um valor numérico</string>
<string name="path_from_uri_null_error_msg">Falha ao carregar a localização selecionada.</string> <string name="path_error">Falha ao carregar a localização selecionada.</string>
<string name="create_cant_write_error_msg">DroidFS não pode salvar neste local. Por favor, tente algum outro.</string> <string name="create_cant_write_error_msg">DroidFS não pode salvar neste local. Por favor, tente algum outro.</string>
<string name="sdcard_error_header">DroidFS só pode escrever em memórias SD removíveis sob:</string> <string name="sdcard_error_header">DroidFS só pode escrever em memórias SD removíveis sob:</string>
<string name="slideshow_stopped">Apresentação parou</string> <string name="slideshow_stopped">Apresentação parou</string>

View File

@ -123,7 +123,7 @@
<string name="move_success">Перемещение выполнено!</string> <string name="move_success">Перемещение выполнено!</string>
<string name="enter_timer_duration">Введите время таймера (в сек.)</string> <string name="enter_timer_duration">Введите время таймера (в сек.)</string>
<string name="timer_empty_error_msg">Введите числовое значение.</string> <string name="timer_empty_error_msg">Введите числовое значение.</string>
<string name="path_from_uri_null_error_msg">Невозможно получить выбранный путь.</string> <string name="path_error">Невозможно получить выбранный путь.</string>
<string name="create_cant_write_error_msg">DroidFS не имеет доступа на запись по этому пути. Попробуйте найти другое место.</string> <string name="create_cant_write_error_msg">DroidFS не имеет доступа на запись по этому пути. Попробуйте найти другое место.</string>
<string name="sdcard_error_header">DroidFS может записывать на SD-карты только в:</string> <string name="sdcard_error_header">DroidFS может записывать на SD-карты только в:</string>
<string name="slideshow_stopped">Слайдшоу остановлено</string> <string name="slideshow_stopped">Слайдшоу остановлено</string>

View File

@ -128,7 +128,7 @@
<string name="move_success">Move successful !</string> <string name="move_success">Move successful !</string>
<string name="enter_timer_duration">Enter the timer duration (in s)</string> <string name="enter_timer_duration">Enter the timer duration (in s)</string>
<string name="timer_empty_error_msg">Please enter a numeric value</string> <string name="timer_empty_error_msg">Please enter a numeric value</string>
<string name="path_from_uri_null_error_msg">Failed to retrieve the selected path.</string> <string name="path_error">Failed to retrieve the selected path.</string>
<string name="create_cant_write_error_msg">DroidFS doesn\'t have write access to this path. Please try another location.</string> <string name="create_cant_write_error_msg">DroidFS doesn\'t have write access to this path. Please try another location.</string>
<string name="add_read_only">Adding volume with read-only access.</string> <string name="add_read_only">Adding volume with read-only access.</string>
<string name="add_cant_write_warning">DroidFS doesn\'t have write access to this path. Adding volume with read-only access.</string> <string name="add_cant_write_warning">DroidFS doesn\'t have write access to this path. Adding volume with read-only access.</string>
@ -222,4 +222,8 @@
<string name="new_password_hint">New password</string> <string name="new_password_hint">New password</string>
<string name="new_password_confirmation_label">Repeat the new password:</string> <string name="new_password_confirmation_label">Repeat the new password:</string>
<string name="error_marshmallow_required">This feature is only available on Android 6.0 (Marshmallow) or above.</string> <string name="error_marshmallow_required">This feature is only available on Android 6.0 (Marshmallow) or above.</string>
<string name="copy_hidden_volume">Copy to shared storage</string>
<string name="copy_external_volume">Make a hidden copy</string>
<string name="copy_volume_notification">Copying volume…</string>
<string name="hidden_volume_already_exists">A hidden volume with the same name already exists.</string>
</resources> </resources>