Volume copy

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

View File

@ -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)
changePasswordPosition?.let { unselect(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 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<Volume>, 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

View File

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

View File

@ -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<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
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<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:title="@string/change_password"/>
<item
android:id="@+id/copy"
app:showAsAction="never"
android:visible="false"/>
</menu>

View File

@ -128,7 +128,7 @@
<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="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="sdcard_error_header">DroidFS só pode escrever em memórias SD removíveis sob:</string>
<string name="slideshow_stopped">Apresentação parou</string>

View File

@ -123,7 +123,7 @@
<string name="move_success">Перемещение выполнено!</string>
<string name="enter_timer_duration">Введите время таймера (в сек.)</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="sdcard_error_header">DroidFS может записывать на SD-карты только в:</string>
<string name="slideshow_stopped">Слайдшоу остановлено</string>

View File

@ -128,7 +128,7 @@
<string name="move_success">Move successful !</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="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="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>
@ -222,4 +222,8 @@
<string name="new_password_hint">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="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>