mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
fix: cancelling a paste stops the copy and keeps a move's source (#1029)
This commit is contained in:
1 parent
92444946ea
commit
a74ff3d67a
3 files changed
+234
-11
No files matched your search
@@ -90,6 +90,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.droiddeck.launcher.session.SessionPrefs
|
import com.droiddeck.launcher.session.SessionPrefs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -268,6 +269,7 @@ fun FileManagerScreen(
|
|||||||
operationDeterminate = true
|
operationDeterminate = true
|
||||||
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
|
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
|
||||||
isOperationRunning = true
|
isOperationRunning = true
|
||||||
|
val job = coroutineContext[kotlinx.coroutines.Job]
|
||||||
|
|
||||||
var applyToAll: ConflictChoice? = null
|
var applyToAll: ConflictChoice? = null
|
||||||
var failed = 0
|
var failed = 0
|
||||||
@@ -303,6 +305,7 @@ fun FileManagerScreen(
|
|||||||
}
|
}
|
||||||
var lastPct = -1
|
var lastPct = -1
|
||||||
val onProgress = FileOps.ProgressCallback { copied, total ->
|
val onProgress = FileOps.ProgressCallback { copied, total ->
|
||||||
|
job?.ensureActive()
|
||||||
val pct = if (total > 0) ((copied * 100) / total).toInt() else 100
|
val pct = if (total > 0) ((copied * 100) / total).toInt() else 100
|
||||||
if (pct != lastPct) {
|
if (pct != lastPct) {
|
||||||
lastPct = pct
|
lastPct = pct
|
||||||
@@ -311,8 +314,8 @@ fun FileManagerScreen(
|
|||||||
}
|
}
|
||||||
val target = dst
|
val target = dst
|
||||||
val ok = withContext(Dispatchers.IO) {
|
val ok = withContext(Dispatchers.IO) {
|
||||||
if (cut) FileOps.moveWithProgress(src, target, onProgress)
|
if (cut) FileOps.moveWithProgress(src, target, onProgress) { job?.isActive == false }
|
||||||
else FileOps.copyWithProgress(src, target, onProgress)
|
else FileOps.copyWithProgress(src, target, onProgress) { job?.isActive == false }
|
||||||
}
|
}
|
||||||
if (ok) done++ else failed++
|
if (ok) done++ else failed++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,22 +45,25 @@ object FileOps {
|
|||||||
return f.length()
|
return f.length()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
|
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
|
||||||
if (sameFile(src, dst)) return true
|
if (sameFile(src, dst)) return true
|
||||||
|
if (isCancelled()) return false
|
||||||
if (!dst.exists() && src.renameTo(dst)) {
|
if (!dst.exists() && src.renameTo(dst)) {
|
||||||
progress?.onProgress(1, 1)
|
progress?.onProgress(1, 1)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (!copyWithProgress(src, dst, progress)) return false
|
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
|
||||||
|
if (isCancelled()) return false
|
||||||
return delete(src)
|
return delete(src)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
|
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
|
||||||
val total = maxOf(totalSize(src), 1L)
|
val total = maxOf(totalSize(src), 1L)
|
||||||
return copyWithProgress(src, dst, longArrayOf(0), total, progress)
|
return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?): Boolean {
|
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?, isCancelled: () -> Boolean): Boolean {
|
||||||
|
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
|
||||||
if (isSymlink(src)) return true
|
if (isSymlink(src)) return true
|
||||||
if (sameFile(src, dst)) return true
|
if (sameFile(src, dst)) return true
|
||||||
if (src.isDirectory && isWithin(dst, src)) {
|
if (src.isDirectory && isWithin(dst, src)) {
|
||||||
@@ -71,7 +74,7 @@ object FileOps {
|
|||||||
if (!dst.exists() && !dst.mkdirs()) return false
|
if (!dst.exists() && !dst.mkdirs()) return false
|
||||||
var allOk = true
|
var allOk = true
|
||||||
src.list()?.forEach { name ->
|
src.list()?.forEach { name ->
|
||||||
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress)) {
|
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
|
||||||
Log.e(TAG, "Failed to copy: ${File(src, name)}")
|
Log.e(TAG, "Failed to copy: ${File(src, name)}")
|
||||||
allOk = false
|
allOk = false
|
||||||
}
|
}
|
||||||
@@ -81,13 +84,41 @@ object FileOps {
|
|||||||
val parent = dst.parentFile
|
val parent = dst.parentFile
|
||||||
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
|
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
|
||||||
val chunk = 4L * 1024 * 1024
|
val chunk = 4L * 1024 * 1024
|
||||||
|
var staging: File? = null
|
||||||
|
var created = false
|
||||||
|
var output: java.nio.channels.FileChannel? = null
|
||||||
try {
|
try {
|
||||||
|
output = try {
|
||||||
|
java.nio.channels.FileChannel.open(dst.toPath(), java.nio.file.StandardOpenOption.WRITE, java.nio.file.StandardOpenOption.CREATE_NEW)
|
||||||
|
.also { created = true }
|
||||||
|
} catch (e: java.nio.file.FileAlreadyExistsException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val target = if (!created && isSymlink(dst)) dst.canonicalFile else dst
|
||||||
|
var permissions: Set<java.nio.file.attribute.PosixFilePermission>? = null
|
||||||
|
if (output == null) {
|
||||||
|
if (isSymlink(target) || target.parentFile?.isDirectory != true) {
|
||||||
|
throw IOException("Destination symlink target is not a writable directory entry: $dst")
|
||||||
|
}
|
||||||
|
if (target.isDirectory) throw IOException("Destination is a directory: $target")
|
||||||
|
permissions = try {
|
||||||
|
Files.getPosixFilePermissions(target.toPath())
|
||||||
|
} catch (e: java.nio.file.NoSuchFileException) {
|
||||||
|
null
|
||||||
|
} catch (e: UnsupportedOperationException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (target.exists() && !target.canWrite()) throw IOException("Destination is not writable: $target")
|
||||||
|
val directory = Files.createTempDirectory(checkNotNull(target.absoluteFile.parentFile).toPath(), ".droiddeck-copy-")
|
||||||
|
staging = directory.toFile()
|
||||||
|
output = FileOutputStream(File(staging, "file")).channel
|
||||||
|
}
|
||||||
FileInputStream(src).channel.use { input ->
|
FileInputStream(src).channel.use { input ->
|
||||||
FileOutputStream(dst).channel.use { output ->
|
checkNotNull(output).use { channel ->
|
||||||
val size = input.size()
|
val size = input.size()
|
||||||
var position = 0L
|
var position = 0L
|
||||||
while (position < size) {
|
while (position < size) {
|
||||||
val transferred = input.transferTo(position, minOf(chunk, size - position), output)
|
val transferred = input.transferTo(position, minOf(chunk, size - position), channel)
|
||||||
if (transferred <= 0) break
|
if (transferred <= 0) break
|
||||||
position += transferred
|
position += transferred
|
||||||
done[0] += transferred
|
done[0] += transferred
|
||||||
@@ -96,11 +127,28 @@ object FileOps {
|
|||||||
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
|
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
staging?.let { directory ->
|
||||||
|
val copied = File(directory, "file").toPath()
|
||||||
|
if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) {
|
||||||
|
Files.setPosixFilePermissions(copied, permissions)
|
||||||
|
}
|
||||||
|
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
|
||||||
|
Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
|
} catch (e: java.util.concurrent.CancellationException) {
|
||||||
|
if (created) dst.delete()
|
||||||
|
throw e
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Failed to copy file: $src to $dst", e)
|
Log.e(TAG, "Failed to copy file: $src to $dst", e)
|
||||||
dst.delete()
|
if (created) dst.delete()
|
||||||
return false
|
return false
|
||||||
|
} finally {
|
||||||
|
output?.close()
|
||||||
|
staging?.let { directory ->
|
||||||
|
File(directory, "file").delete()
|
||||||
|
directory.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.droiddeck.launcher.files
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.CancellationException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class FileOpsCopyTest {
|
||||||
|
@get:Rule val tmp = TemporaryFolder()
|
||||||
|
|
||||||
|
@Test fun cancellingAnOverwritePreservesTheDestinationAndSource() {
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = tmp.newFile("dest").apply { writeText("original user data") }
|
||||||
|
assertThrows(CancellationException::class.java) {
|
||||||
|
FileOps.copyWithProgress(src, dst, { _, _ -> throw CancellationException("cancel") })
|
||||||
|
}
|
||||||
|
assertTrue("original destination was removed", dst.exists())
|
||||||
|
assertEquals("original user data", dst.readText())
|
||||||
|
assertEquals("replacement", src.readText())
|
||||||
|
assertEquals(setOf("source", "dest"), tmp.root.list()!!.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancellingANewCopyLeavesNoPartialDestination() {
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = File(tmp.root, "dest")
|
||||||
|
assertThrows(CancellationException::class.java) {
|
||||||
|
FileOps.copyWithProgress(src, dst, { _, _ -> throw CancellationException("cancel") })
|
||||||
|
}
|
||||||
|
assertFalse(dst.exists())
|
||||||
|
assertEquals("replacement", src.readText())
|
||||||
|
assertEquals(setOf("source"), tmp.root.list()!!.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancellingAMoveFallbackPreservesBothFiles() {
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = tmp.newFile("dest").apply { writeText("original user data") }
|
||||||
|
assertThrows(CancellationException::class.java) {
|
||||||
|
FileOps.moveWithProgress(src, dst, { _, _ -> throw CancellationException("cancel") })
|
||||||
|
}
|
||||||
|
assertTrue("original destination was removed", dst.exists())
|
||||||
|
assertEquals("original user data", dst.readText())
|
||||||
|
assertEquals("replacement", src.readText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancellingStopsATreeOfEmptyFiles() {
|
||||||
|
val src = tmp.newFolder("source")
|
||||||
|
for (name in listOf("a", "b", "c")) File(src, name).createNewFile()
|
||||||
|
val dst = File(tmp.root, "dest")
|
||||||
|
var checks = 0
|
||||||
|
assertThrows(CancellationException::class.java) {
|
||||||
|
FileOps.copyWithProgress(src, dst, null) { checks++ > 1 }
|
||||||
|
}
|
||||||
|
assertEquals(1, dst.list()!!.size)
|
||||||
|
assertEquals(3, src.list()!!.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancellingStopsATreeOfEmptyFolders() {
|
||||||
|
val src = tmp.newFolder("source")
|
||||||
|
for (name in listOf("a", "b", "c")) File(src, name).mkdir()
|
||||||
|
val dst = File(tmp.root, "dest")
|
||||||
|
var checks = 0
|
||||||
|
assertThrows(CancellationException::class.java) {
|
||||||
|
FileOps.copyWithProgress(src, dst, null) { checks++ > 1 }
|
||||||
|
}
|
||||||
|
assertEquals(1, dst.list()!!.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aCompletedCopyReplacesTheDestination() {
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = tmp.newFile("dest").apply { writeText("original user data") }
|
||||||
|
assertTrue(FileOps.copyWithProgress(src, dst, null))
|
||||||
|
assertEquals("replacement", dst.readText())
|
||||||
|
assertEquals("replacement", src.readText())
|
||||||
|
assertEquals(setOf("source", "dest"), tmp.root.list()!!.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aLongDestinationNameDoesNotOverflowTheStagingName() {
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = File(tmp.root, "d".repeat(240))
|
||||||
|
assertTrue(FileOps.copyWithProgress(src, dst, null))
|
||||||
|
assertEquals("replacement", dst.readText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anOverwritePreservesTheTargetsExecutablePermissions() {
|
||||||
|
assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix"))
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = tmp.newFile("dest").apply { writeText("original") }
|
||||||
|
val permissions = PosixFilePermissions.fromString("rwxr-x---")
|
||||||
|
Files.setPosixFilePermissions(dst.toPath(), permissions)
|
||||||
|
assertTrue(FileOps.copyWithProgress(src, dst, null))
|
||||||
|
assertEquals("replacement", dst.readText())
|
||||||
|
assertEquals(permissions, Files.getPosixFilePermissions(dst.toPath()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aMoveFallbackPreservesTheTargetsExecutablePermissions() {
|
||||||
|
assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix"))
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = tmp.newFile("dest").apply { writeText("original") }
|
||||||
|
val permissions = PosixFilePermissions.fromString("rwxr-x---")
|
||||||
|
Files.setPosixFilePermissions(dst.toPath(), permissions)
|
||||||
|
assertTrue(FileOps.moveWithProgress(src, dst, null))
|
||||||
|
assertFalse(src.exists())
|
||||||
|
assertEquals("replacement", dst.readText())
|
||||||
|
assertEquals(permissions, Files.getPosixFilePermissions(dst.toPath()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anOverwriteStillRefusesAReadOnlyDestination() {
|
||||||
|
assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix"))
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = tmp.newFile("dest").apply { writeText("original") }
|
||||||
|
Files.setPosixFilePermissions(dst.toPath(), PosixFilePermissions.fromString("r--r--r--"))
|
||||||
|
try {
|
||||||
|
assertFalse(FileOps.copyWithProgress(src, dst, null))
|
||||||
|
assertEquals("original", dst.readText())
|
||||||
|
assertEquals("replacement", src.readText())
|
||||||
|
} finally {
|
||||||
|
dst.setWritable(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anEmptyFileCanReplaceAnExistingFile() {
|
||||||
|
val src = tmp.newFile("source")
|
||||||
|
val dst = tmp.newFile("dest").apply { writeText("original user data") }
|
||||||
|
var callbacks = 0
|
||||||
|
assertTrue(FileOps.copyWithProgress(src, dst, { _, _ -> callbacks++ }))
|
||||||
|
assertEquals(0, callbacks)
|
||||||
|
assertEquals(0L, dst.length())
|
||||||
|
assertTrue(src.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aFailedPublicationPreservesTheDestinationAndCleansStaging() {
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = tmp.newFolder("dest")
|
||||||
|
File(dst, "keep").writeText("original user data")
|
||||||
|
assertFalse(FileOps.copyWithProgress(src, dst, null))
|
||||||
|
assertEquals("original user data", File(dst, "keep").readText())
|
||||||
|
assertEquals("replacement", src.readText())
|
||||||
|
assertEquals(setOf("source", "dest"), tmp.root.list()!!.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aSuccessfulMoveFallbackDeletesTheSourceOnlyAfterCopying() {
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val dst = tmp.newFile("dest").apply { writeText("original user data") }
|
||||||
|
assertTrue(FileOps.moveWithProgress(src, dst, null))
|
||||||
|
assertFalse(src.exists())
|
||||||
|
assertEquals("replacement", dst.readText())
|
||||||
|
assertEquals(setOf("dest"), tmp.root.list()!!.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun overwritingThroughASymlinkKeepsTheLinkAndUpdatesItsTarget() {
|
||||||
|
assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix"))
|
||||||
|
val src = tmp.newFile("source").apply { writeText("replacement") }
|
||||||
|
val realTarget = tmp.newFile("real-target").apply { writeText("original user data") }
|
||||||
|
val link = File(tmp.root, "dest-link")
|
||||||
|
Files.createSymbolicLink(link.toPath(), realTarget.toPath())
|
||||||
|
|
||||||
|
assertTrue(FileOps.copyWithProgress(src, link, null))
|
||||||
|
assertTrue("destination symlink was replaced instead of written through", Files.isSymbolicLink(link.toPath()))
|
||||||
|
assertEquals("replacement", realTarget.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user