forked from hardcoresushi/DroidFS
Volume copy
This commit is contained in:
parent
bea0906f65
commit
e01b5a3098
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -34,4 +34,9 @@
|
||||
android:visible="false"
|
||||
android:title="@string/change_password"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/copy"
|
||||
app:showAsAction="never"
|
||||
android:visible="false"/>
|
||||
|
||||
</menu>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user