Make delete, rename and move undoable
A delete is no longer a removal: it is a move into a hidden .plainfiles-trash folder on the same volume, which costs a rename and keeps the item recoverable. The snackbar offers Undo for delete, rename and a pasted cut; the handle is cleared as soon as it is used. domain/Trash carries four deliberate limits: the trash lives on the users own volume, so it counts against their free space until it expires (24 hours, purged opportunistically, no startup hook and no empty-trash chore); a name taken again in the meantime is never overwritten, so an item returns beside it; a delete through Androids own dialog is not staged because the system owns it; and each item is staged in its own slot so two files sharing a name cannot overwrite each other on the way in. When a pasted move landing names cannot be established with confidence, no undo is offered rather than risking the wrong file moving back.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
package de.softwareapp_hb.filemanager.domain
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.data.StorageSource
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** Where one trashed item came from, so it can be put back exactly where it was. */
|
||||
data class TrashedEntry(val original: StoragePath, val staged: StoragePath)
|
||||
|
||||
/** One staged delete: everything [Trash.stage] moved aside, and where it went. */
|
||||
data class TrashBatch(val folder: StoragePath, val entries: List<TrashedEntry>)
|
||||
|
||||
/**
|
||||
* A staging folder that makes a delete undoable.
|
||||
*
|
||||
* A delete cannot be reversed from the outside — once the bytes are gone they are gone —
|
||||
* so a delete the app performs itself is really a *move* into a hidden folder on the same
|
||||
* volume. Same-volume means the move is a rename rather than a copy, so it costs nothing
|
||||
* and works on a multi-gigabyte file.
|
||||
*
|
||||
* Four deliberate decisions:
|
||||
*
|
||||
* - **Each item gets its own staging folder.** `<trash>/<batch>/<n>/<name>` means the item
|
||||
* keeps its original name, so putting it back is exact, and two files called
|
||||
* `report.pdf` from different folders can never collide on the way in.
|
||||
* - **The trash sits on the user's volume**, so its contents count against their free
|
||||
* space until they expire. That is the honest cost of a recoverable delete. The name
|
||||
* starts with a dot — the same convention Android uses for `.nomedia` — so the folder is
|
||||
* hidden from listings by default.
|
||||
* - **Retention is a time window, not a bin to manage.** [purgeExpired] drops batches older
|
||||
* than [RETENTION_HOURS]; the caller runs it after staging, so space comes back without
|
||||
* the app needing a startup hook or the user needing to empty anything.
|
||||
* - **A delete that went through the platform's own dialog is never staged.** Android owns
|
||||
* that deletion, and its own trash with it, so it is not ours to reverse.
|
||||
*/
|
||||
class Trash(
|
||||
private val source: StorageSource,
|
||||
private val volumeRoot: StoragePath,
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
|
||||
/** The hidden folder batches live in, on this trash's volume. */
|
||||
fun root(): StoragePath = volumeRoot.child(TRASH_DIR_NAME)
|
||||
|
||||
/**
|
||||
* Move [paths] aside so the delete can be taken back.
|
||||
*
|
||||
* The returned batch is `null` when nothing could be staged, so a caller can never end
|
||||
* up holding an undo handle for work that did not happen.
|
||||
*/
|
||||
suspend fun stage(
|
||||
paths: List<StoragePath>,
|
||||
onProgress: (OperationProgress) -> Unit = {},
|
||||
): Pair<TrashBatch?, OperationResult> {
|
||||
if (paths.isEmpty()) {
|
||||
return null to OperationResult(OperationKind.DELETE, 0, emptyList(), false)
|
||||
}
|
||||
|
||||
val batch = root().child(BATCH_PREFIX + clock())
|
||||
val operations = FileOperations(source)
|
||||
val staged = ArrayList<TrashedEntry>()
|
||||
val failures = ArrayList<OperationFailure>()
|
||||
var cancelled = false
|
||||
|
||||
try {
|
||||
source.createDirectory(root())
|
||||
source.createDirectory(batch)
|
||||
|
||||
for ((index, path) in paths.withIndex()) {
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
cancelled = true
|
||||
break
|
||||
}
|
||||
|
||||
val slot = batch.child(index.toString())
|
||||
source.createDirectory(slot)
|
||||
val result = operations.move(listOf(path), slot, ConflictPolicy.KEEP_BOTH)
|
||||
|
||||
if (result.succeeded) {
|
||||
// The item keeps its own name inside its own slot, so this is exact.
|
||||
staged += TrashedEntry(original = path, staged = slot.child(path.name))
|
||||
} else {
|
||||
failures += result.failures
|
||||
// Nothing landed here, so do not leave an empty slot behind.
|
||||
source.delete(slot)
|
||||
}
|
||||
onProgress(OperationProgress(OperationKind.DELETE, index + 1, paths.size, path))
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
return null to OperationResult(
|
||||
OperationKind.DELETE,
|
||||
0,
|
||||
listOf(OperationFailure(paths.first(), failure.message ?: "Could not be moved aside")),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
val outcome = OperationResult(OperationKind.DELETE, staged.size, failures, cancelled)
|
||||
return batch.takeIf { staged.isNotEmpty() }?.let { TrashBatch(it, staged) } to outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a staged batch back where it came from.
|
||||
*
|
||||
* A name that has been taken again in the meantime is not overwritten: the item comes
|
||||
* back alongside it under a free name, because losing a *new* file in order to undo an
|
||||
* old delete would be a poor trade.
|
||||
*/
|
||||
suspend fun restore(
|
||||
batch: TrashBatch,
|
||||
onProgress: (OperationProgress) -> Unit = {},
|
||||
): OperationResult {
|
||||
val operations = FileOperations(source)
|
||||
val failures = ArrayList<OperationFailure>()
|
||||
var restored = 0
|
||||
|
||||
for ((index, entry) in batch.entries.withIndex()) {
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
return OperationResult(OperationKind.MOVE, restored, failures, cancelled = true)
|
||||
}
|
||||
|
||||
val parent = entry.original.parent
|
||||
val result = when {
|
||||
parent == null || !source.exists(entry.staged) -> OperationResult(
|
||||
OperationKind.MOVE,
|
||||
0,
|
||||
listOf(OperationFailure(entry.original, "It is no longer in the trash")),
|
||||
false,
|
||||
)
|
||||
|
||||
else -> operations.move(listOf(entry.staged), parent, ConflictPolicy.KEEP_BOTH)
|
||||
}
|
||||
|
||||
if (result.succeeded) {
|
||||
restored++
|
||||
onProgress(OperationProgress(OperationKind.MOVE, index + 1, batch.entries.size, entry.original))
|
||||
} else {
|
||||
failures += result.failures
|
||||
}
|
||||
}
|
||||
|
||||
return OperationResult(OperationKind.MOVE, restored, failures, false)
|
||||
}
|
||||
|
||||
/** Remove batches older than [RETENTION_HOURS]. Returns how many were dropped. */
|
||||
suspend fun purgeExpired(): Int {
|
||||
val root = root()
|
||||
if (!source.exists(root)) return 0
|
||||
|
||||
val expiry = clock() - RETENTION_MILLIS
|
||||
var purged = 0
|
||||
for (child in source.list(root)) {
|
||||
val stagedAt = batchTimestamp(child.name) ?: continue
|
||||
if (stagedAt >= expiry) continue
|
||||
if (source.delete(child.path)) purged++
|
||||
}
|
||||
return purged
|
||||
}
|
||||
|
||||
/**
|
||||
* The epoch millis a batch folder was created, or `null` when the name is not one of
|
||||
* ours.
|
||||
*
|
||||
* The age is carried in the name rather than read from the folder's timestamp, which
|
||||
* keeps expiry a pure function that a test can drive without touching the clock.
|
||||
*/
|
||||
fun batchTimestamp(folderName: String): Long? = folderName
|
||||
.takeIf { it.startsWith(BATCH_PREFIX) }
|
||||
?.removePrefix(BATCH_PREFIX)
|
||||
?.toLongOrNull()
|
||||
|
||||
companion object {
|
||||
/** Leading dot, so the folder is hidden from listings exactly like `.nomedia`. */
|
||||
const val TRASH_DIR_NAME = ".plainfiles-trash"
|
||||
|
||||
/**
|
||||
* The trash a batch belongs to, derived from where the batch actually sits —
|
||||
* `<volume>/.plainfiles-trash/batch-n` — so a restore needs no volume lookup.
|
||||
*/
|
||||
fun of(source: StorageSource, batch: TrashBatch): Trash =
|
||||
Trash(source, batch.folder.parent?.parent ?: StoragePath.Root)
|
||||
|
||||
private const val BATCH_PREFIX = "batch-"
|
||||
|
||||
/** How long a staged delete stays recoverable. */
|
||||
const val RETENTION_HOURS = 24L
|
||||
|
||||
const val RETENTION_MILLIS = RETENTION_HOURS * 60L * 60L * 1000L
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -151,9 +152,12 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
|
||||
pluralStringResource(it.textRes, quantity, *it.args.toTypedArray())
|
||||
} ?: stringResource(it.textRes, *it.args.toTypedArray())
|
||||
}
|
||||
// Resolved out here because `stringResource` is a composable call and the effect is not.
|
||||
val undoLabel = message?.actionLabelRes?.let { stringResource(it) }
|
||||
LaunchedEffect(message?.id) {
|
||||
if (messageText != null) {
|
||||
snackbarHostState.showSnackbar(messageText)
|
||||
val outcome = snackbarHostState.showSnackbar(messageText, actionLabel = undoLabel)
|
||||
if (outcome == SnackbarResult.ActionPerformed) viewModel.undo()
|
||||
viewModel.consumeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
import de.softwareapp_hb.filemanager.core.SortSpec
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.data.StorageAccessMode
|
||||
import de.softwareapp_hb.filemanager.domain.TrashBatch
|
||||
import de.softwareapp_hb.filemanager.domain.UsageNode
|
||||
|
||||
enum class ViewMode { LIST, GRID }
|
||||
@@ -24,6 +25,8 @@ data class UiMessage(
|
||||
val args: List<Any> = emptyList(),
|
||||
/** Set when [textRes] names a `<plurals>` resource; the UI resolves it accordingly. */
|
||||
val pluralQuantity: Int? = null,
|
||||
/** When set, the snackbar offers a single action button carrying this label. */
|
||||
@StringRes val actionLabelRes: Int? = null,
|
||||
val id: Long = System.nanoTime(),
|
||||
)
|
||||
|
||||
@@ -64,6 +67,27 @@ data class DetailFocus(
|
||||
val loading: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* The last change to the filesystem the app made itself, and how to take it back.
|
||||
*
|
||||
* One step deep — an undo stack would be a feature in its own right — and never set for
|
||||
* work Android performed: a delete that went through the platform's own dialog is the
|
||||
* system's to reverse, not ours.
|
||||
*/
|
||||
sealed interface Undo {
|
||||
/**
|
||||
* Move each item back where it came from. Covers a rename and a pasted cut alike,
|
||||
* because both are the same operation seen from different angles.
|
||||
*/
|
||||
data class Relocate(val moves: List<UndoMove>) : Undo
|
||||
|
||||
/** Bring a staged delete back out of the trash. */
|
||||
data class Trashed(val batch: TrashBatch) : Undo
|
||||
}
|
||||
|
||||
/** [from] is where the item is now; [to] is the full path it had before. */
|
||||
data class UndoMove(val from: StoragePath, val to: StoragePath)
|
||||
|
||||
/** A full-screen view the browser hands over to. */
|
||||
sealed interface Overlay {
|
||||
data object None : Overlay
|
||||
@@ -110,6 +134,8 @@ data class BrowserUiState(
|
||||
/** Set when the system should confirm a delete; the screen launches it and clears it. */
|
||||
val pendingDeleteRequest: IntentSenderRequest? = null,
|
||||
val conflict: ConflictPrompt? = null,
|
||||
/** The last destructive change the app made, waiting to be taken back. */
|
||||
val undo: Undo? = null,
|
||||
/** A viewer, editor or the storage-usage view taking over the screen. */
|
||||
val overlay: Overlay = Overlay.None,
|
||||
) {
|
||||
|
||||
@@ -25,18 +25,23 @@ import de.softwareapp_hb.filemanager.core.TransferAction
|
||||
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
|
||||
import de.softwareapp_hb.filemanager.data.StorageAccessMode
|
||||
import de.softwareapp_hb.filemanager.data.StorageLocation
|
||||
import de.softwareapp_hb.filemanager.data.StorageSource
|
||||
import de.softwareapp_hb.filemanager.domain.ArchiveEngine
|
||||
import de.softwareapp_hb.filemanager.domain.FileOperations
|
||||
import de.softwareapp_hb.filemanager.domain.OperationFailure
|
||||
import de.softwareapp_hb.filemanager.domain.OperationKind
|
||||
import de.softwareapp_hb.filemanager.domain.OperationNotifier
|
||||
import de.softwareapp_hb.filemanager.domain.OperationResult
|
||||
import de.softwareapp_hb.filemanager.domain.Trash
|
||||
import de.softwareapp_hb.filemanager.domain.UsageAnalysis
|
||||
import de.softwareapp_hb.filemanager.domain.UsageNode
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -529,6 +534,11 @@ class BrowserViewModel(
|
||||
|
||||
_state.update { it.copy(loading = true) }
|
||||
notifier.start(kind)
|
||||
|
||||
// A move is destructive, so how to reverse it is worked out by diffing the target's
|
||||
// listing either side of the operation — see [matchLanded].
|
||||
val landedBefore = if (cut) targetNames(source, target) else emptySet()
|
||||
|
||||
val result: OperationResult = try {
|
||||
if (cut) {
|
||||
operations.move(paths, target, policy) { notifier.update(it) }
|
||||
@@ -543,7 +553,14 @@ class BrowserViewModel(
|
||||
|
||||
_state.update { it.copy(loading = false, clipboard = if (cut) null else it.clipboard) }
|
||||
if (result.succeeded) {
|
||||
messagePlural(if (cut) R.plurals.message_moved else R.plurals.message_copied, result.completed)
|
||||
if (cut) {
|
||||
matchLanded(target, landedBefore, targetNames(source, target), paths)?.let { moves ->
|
||||
_state.update { it.copy(undo = Undo.Relocate(moves)) }
|
||||
}
|
||||
messageUndoablePlural(R.plurals.message_moved, result.completed)
|
||||
} else {
|
||||
messagePlural(R.plurals.message_copied, result.completed)
|
||||
}
|
||||
} else {
|
||||
report(result)
|
||||
}
|
||||
@@ -552,6 +569,78 @@ class BrowserViewModel(
|
||||
if (here == null) refresh() else load(here)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- undo
|
||||
|
||||
/**
|
||||
* Take back the last destructive change.
|
||||
*
|
||||
* The handle is cleared the moment it is used, so a second tap on the same snackbar
|
||||
* cannot replay it against files that have already moved.
|
||||
*/
|
||||
fun undo() {
|
||||
val pending = _state.value.undo ?: return
|
||||
val source = storage.activeSource() ?: return
|
||||
_state.update { it.copy(undo = null) }
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true) }
|
||||
notifier.start(OperationKind.MOVE)
|
||||
val result = try {
|
||||
when (pending) {
|
||||
is Undo.Relocate -> relocateBack(source, pending.moves)
|
||||
|
||||
is Undo.Trashed ->
|
||||
Trash.of(source, pending.batch).restore(pending.batch) { notifier.update(it) }
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} finally {
|
||||
notifier.stop()
|
||||
}
|
||||
_state.update { it.copy(loading = false) }
|
||||
|
||||
if (result.succeeded) {
|
||||
messagePlural(R.plurals.message_undone, result.completed)
|
||||
} else {
|
||||
report(result)
|
||||
}
|
||||
|
||||
val here = _state.value.path
|
||||
if (here == null) refresh() else load(here)
|
||||
}
|
||||
}
|
||||
|
||||
/** Move each item in [moves] back to the exact path it came from. */
|
||||
private suspend fun relocateBack(source: StorageSource, moves: List<UndoMove>): OperationResult {
|
||||
val operations = FileOperations(source)
|
||||
val failures = ArrayList<OperationFailure>()
|
||||
var restored = 0
|
||||
|
||||
for (move in moves) {
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
return OperationResult(OperationKind.MOVE, restored, failures, cancelled = true)
|
||||
}
|
||||
|
||||
val home = move.to.parent
|
||||
val done = when {
|
||||
// The exact path is what undo means: the old *name* matters, not just the
|
||||
// old folder, which is why this is a rename rather than a move.
|
||||
runCatching { operations.rename(move.from, move.to) }.getOrDefault(false) -> true
|
||||
|
||||
// The old name has been taken again meanwhile. Come back alongside it rather
|
||||
// than overwriting whatever holds it now.
|
||||
home != null ->
|
||||
operations.move(listOf(move.from), home, ConflictPolicy.KEEP_BOTH).succeeded
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (done) restored++ else failures += OperationFailure(move.to, "It could not be put back")
|
||||
}
|
||||
|
||||
return OperationResult(OperationKind.MOVE, restored, failures, false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mutations
|
||||
|
||||
fun createFolder(name: String) {
|
||||
@@ -576,7 +665,19 @@ class BrowserViewModel(
|
||||
viewModelScope.launch {
|
||||
val renamed = runCatching { FileOperations(source).rename(path, parent.child(cleanName)) }
|
||||
.getOrDefault(false)
|
||||
message(if (renamed) R.string.message_renamed else R.string.message_name_in_use)
|
||||
if (renamed) {
|
||||
// A rename is a move within a single folder, so it needs no separate shape.
|
||||
_state.update {
|
||||
it.copy(
|
||||
undo = Undo.Relocate(
|
||||
listOf(UndoMove(from = parent.child(cleanName), to = path)),
|
||||
),
|
||||
)
|
||||
}
|
||||
messageUndoable(R.string.message_renamed)
|
||||
} else {
|
||||
message(R.string.message_name_in_use)
|
||||
}
|
||||
_state.value.path?.let { load(it) }
|
||||
}
|
||||
}
|
||||
@@ -611,31 +712,91 @@ class BrowserViewModel(
|
||||
if (here == null) refresh() else load(here)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete by moving into the trash, so the delete can be taken back.
|
||||
*
|
||||
* Nothing is actually removed here: [Trash.stage] relocates the items into a hidden
|
||||
* folder on the same volume, which costs a rename and keeps them recoverable. Expiry is
|
||||
* time-based and handled by [Trash], so the space returns on its own and the user never
|
||||
* has to empty anything.
|
||||
*/
|
||||
private suspend fun deleteDirectly(paths: List<StoragePath>) {
|
||||
val source = storage.activeSource() ?: return
|
||||
val operations = FileOperations(source)
|
||||
val trash = Trash(source, volumeRootFor(paths))
|
||||
|
||||
_state.update { it.copy(loading = true, selection = emptySet()) }
|
||||
notifier.start(OperationKind.DELETE)
|
||||
val result = try {
|
||||
operations.delete(paths) { notifier.update(it) }
|
||||
val (batch, result) = try {
|
||||
trash.stage(paths) { notifier.update(it) }
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} finally {
|
||||
notifier.stop()
|
||||
}
|
||||
_state.update { it.copy(loading = false) }
|
||||
_state.update { it.copy(loading = false, undo = batch?.let(Undo::Trashed)) }
|
||||
|
||||
if (result.succeeded) {
|
||||
messagePlural(R.plurals.message_deleted, result.completed)
|
||||
messageUndoablePlural(R.plurals.message_deleted_undo, result.completed)
|
||||
} else {
|
||||
report(result)
|
||||
}
|
||||
|
||||
val here = _state.value.path
|
||||
if (here == null) refresh() else load(here)
|
||||
|
||||
// Whatever has expired goes now, so there is no startup hook to forget and no chore
|
||||
// for the user. Nothing here can fail the delete that just succeeded.
|
||||
runCatching { trash.purgeExpired() }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ undo bookkeeping
|
||||
|
||||
/**
|
||||
* Pair each moved item with the name it actually landed under, or `null` when that
|
||||
* cannot be established with confidence.
|
||||
*
|
||||
* The engine renames an incoming item when the target already has that name, so where
|
||||
* something ended up has to be *discovered* by diffing the target listing rather than
|
||||
* assumed from its original name. An exact name match is preferred; whatever is left
|
||||
* over belongs to the renamed item. If the counts do not line up, this gives up
|
||||
* entirely — silently moving the wrong file back would be far worse than not offering
|
||||
* an undo at all.
|
||||
*/
|
||||
private fun matchLanded(
|
||||
target: StoragePath,
|
||||
before: Set<String>,
|
||||
after: Set<String>,
|
||||
originals: List<StoragePath>,
|
||||
): List<UndoMove>? {
|
||||
val added = after.filterNot { it in before }
|
||||
if (added.size != originals.size) return null
|
||||
|
||||
val unclaimed = added.toMutableList()
|
||||
val moves = ArrayList<UndoMove>(originals.size)
|
||||
for (original in originals) {
|
||||
val landed = unclaimed.firstOrNull { it == original.name }
|
||||
?: unclaimed.firstOrNull()
|
||||
?: return null
|
||||
unclaimed.remove(landed)
|
||||
moves += UndoMove(from = target.child(landed), to = original)
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
private suspend fun targetNames(source: StorageSource, target: StoragePath): Set<String> =
|
||||
runCatching { source.list(target).mapTo(HashSet()) { it.name } }.getOrDefault(emptySet())
|
||||
|
||||
/**
|
||||
* The volume holding [paths], so the trash lands on the same filesystem as the items
|
||||
* and the move into it is a rename rather than a copy. The deepest matching root wins,
|
||||
* because one volume root can sit inside another.
|
||||
*/
|
||||
private fun volumeRootFor(paths: List<StoragePath>): StoragePath = storage.volumes()
|
||||
.map { it.root }
|
||||
.filter { root -> paths.any { it == root || it.isDescendantOf(root) } }
|
||||
.maxByOrNull { it.segments.size }
|
||||
?: StoragePath.Root
|
||||
|
||||
// ----------------------------------------------------------------- messages
|
||||
|
||||
fun consumeMessage() {
|
||||
@@ -650,11 +811,33 @@ class BrowserViewModel(
|
||||
}
|
||||
|
||||
private fun message(@StringRes textRes: Int, vararg args: Any) {
|
||||
_state.update { it.copy(message = UiMessage(textRes, args.toList())) }
|
||||
postMessage(UiMessage(textRes, args.toList()))
|
||||
}
|
||||
|
||||
private fun messagePlural(@PluralsRes textRes: Int, count: Int) {
|
||||
_state.update { it.copy(message = UiMessage(textRes, listOf(count), pluralQuantity = count)) }
|
||||
postMessage(UiMessage(textRes, listOf(count), pluralQuantity = count))
|
||||
}
|
||||
|
||||
/** A message whose snackbar also offers to take the change back. */
|
||||
private fun messageUndoable(@StringRes textRes: Int, vararg args: Any) {
|
||||
postMessage(
|
||||
UiMessage(textRes, args.toList(), actionLabelRes = R.string.browser_action_undo),
|
||||
)
|
||||
}
|
||||
|
||||
private fun messageUndoablePlural(@PluralsRes textRes: Int, count: Int) {
|
||||
postMessage(
|
||||
UiMessage(
|
||||
textRes,
|
||||
listOf(count),
|
||||
pluralQuantity = count,
|
||||
actionLabelRes = R.string.browser_action_undo,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun postMessage(next: UiMessage) {
|
||||
_state.update { it.copy(message = next) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<string name="browser_action_share">Share</string>
|
||||
<string name="browser_action_select_all">Select all</string>
|
||||
<string name="browser_action_clear_selection">Clear selection</string>
|
||||
<string name="browser_action_undo">Undo</string>
|
||||
<string name="browser_action_more">More options</string>
|
||||
<string name="browser_action_refresh">Refresh</string>
|
||||
<string name="browser_action_cancel">Cancel</string>
|
||||
@@ -130,9 +131,14 @@
|
||||
<item quantity="one">Moved %1$d item</item>
|
||||
<item quantity="other">Moved %1$d items</item>
|
||||
</plurals>
|
||||
<plurals name="message_deleted">
|
||||
<item quantity="one">Deleted %1$d item</item>
|
||||
<item quantity="other">Deleted %1$d items</item>
|
||||
<!-- What a delete says now that it stages instead of removing: recoverable, not gone. -->
|
||||
<plurals name="message_deleted_undo">
|
||||
<item quantity="one">Moved %1$d item to the trash</item>
|
||||
<item quantity="other">Moved %1$d items to the trash</item>
|
||||
</plurals>
|
||||
<plurals name="message_undone">
|
||||
<item quantity="one">Put %1$d item back</item>
|
||||
<item quantity="other">Put %1$d items back</item>
|
||||
</plurals>
|
||||
<plurals name="message_failed">
|
||||
<item quantity="one">%1$d item could not be processed</item>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package de.softwareapp_hb.filemanager.domain
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The trash is what makes a delete undoable, so these tests care about the two things that
|
||||
* could silently lose a file: a name collision on the way in, and a collision on the way
|
||||
* back out.
|
||||
*/
|
||||
class TrashTest {
|
||||
|
||||
private val volume = StoragePath.of("/storage/emulated/0")
|
||||
private val docs = volume.child("Documents")
|
||||
private val notes = volume.child("notes.txt")
|
||||
|
||||
private fun storage() = InMemoryStorageSource().apply {
|
||||
addDirectory(volume)
|
||||
addDirectory(docs)
|
||||
addFile(notes, "hello")
|
||||
addFile(docs.child("report.pdf"), "report")
|
||||
}
|
||||
|
||||
private fun trash(source: InMemoryStorageSource, at: Long = TRASHED_AT) =
|
||||
Trash(source, volume, clock = { at })
|
||||
|
||||
@Test
|
||||
fun `staging moves an item aside and records where it came from`() = runBlocking {
|
||||
val storage = storage()
|
||||
|
||||
val (batch, result) = trash(storage).stage(listOf(notes))
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertNotNull(batch)
|
||||
assertFalse("the item is no longer where it was", storage.contains(notes))
|
||||
assertEquals(notes, batch!!.entries.single().original)
|
||||
assertEquals("hello", storage.contentOf(batch.entries.single().staged))
|
||||
assertTrue(batch.entries.single().staged.isDescendantOf(batch.folder))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring puts an item back exactly where it was`() = runBlocking {
|
||||
val storage = storage()
|
||||
val (batch, _) = trash(storage).stage(listOf(notes))
|
||||
|
||||
val result = trash(storage).restore(batch!!)
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals(1, result.completed)
|
||||
assertTrue(storage.contains(notes))
|
||||
assertEquals("hello", storage.contentOf(notes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a folder comes back whole`() = runBlocking {
|
||||
val storage = storage()
|
||||
val (batch, _) = trash(storage).stage(listOf(docs))
|
||||
assertFalse(storage.contains(docs))
|
||||
|
||||
trash(storage).restore(batch!!)
|
||||
|
||||
assertTrue(storage.contains(docs))
|
||||
assertEquals("report", storage.contentOf(docs.child("report.pdf")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two items sharing a name stage without one overwriting the other`() = runBlocking {
|
||||
val storage = storage()
|
||||
val other = volume.child("Backups")
|
||||
storage.addDirectory(other)
|
||||
storage.addFile(other.child("notes.txt"), "other notes")
|
||||
|
||||
val (batch, result) = trash(storage).stage(listOf(notes, other.child("notes.txt")))
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals(2, batch!!.entries.size)
|
||||
// Each item gets its own slot, so neither has to be renamed on the way in.
|
||||
assertEquals("hello", storage.contentOf(batch.entries[0].staged))
|
||||
assertEquals("other notes", storage.contentOf(batch.entries[1].staged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring never overwrites a name that was taken again meanwhile`() = runBlocking {
|
||||
val storage = storage()
|
||||
val (batch, _) = trash(storage).stage(listOf(notes))
|
||||
storage.addFile(notes, "a new file holding the old name")
|
||||
|
||||
trash(storage).restore(batch!!)
|
||||
|
||||
// The new file survives untouched, and the old one came back beside it.
|
||||
assertEquals("a new file holding the old name", storage.contentOf(notes))
|
||||
val beside = storage.list(volume).map { it.name }
|
||||
.filter { it.startsWith("notes") && it != notes.name }
|
||||
assertEquals(1, beside.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expiry drops only the batches that are old enough`() = runBlocking {
|
||||
val storage = storage()
|
||||
val (old, _) = trash(storage, at = 0L).stage(listOf(notes))
|
||||
|
||||
// Put it back, so there is something left to stage a second time.
|
||||
trash(storage, at = 0L).restore(old!!)
|
||||
val (fresh, _) = trash(storage, at = Trash.RETENTION_MILLIS + 1).stage(listOf(notes))
|
||||
|
||||
val purged = trash(storage, at = Trash.RETENTION_MILLIS + 2).purgeExpired()
|
||||
|
||||
assertEquals(1, purged)
|
||||
assertFalse(storage.contains(old.folder))
|
||||
assertTrue("the recent batch stays recoverable", storage.contains(fresh!!.folder))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `staging nothing yields no undo handle`() = runBlocking {
|
||||
val (batch, result) = trash(storage()).stage(emptyList())
|
||||
|
||||
assertNull(batch)
|
||||
assertEquals(0, result.completed)
|
||||
assertTrue(result.succeeded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring a batch that is already gone is reported, not thrown`() = runBlocking {
|
||||
val storage = storage()
|
||||
val (batch, _) = trash(storage).stage(listOf(notes))
|
||||
storage.delete(batch!!.folder)
|
||||
|
||||
val result = trash(storage).restore(batch)
|
||||
|
||||
assertFalse(result.succeeded)
|
||||
assertEquals(1, result.failures.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a batch folder is recognised by its name, and nothing else is`() {
|
||||
val trash = trash(storage())
|
||||
|
||||
assertEquals(1234L, trash.batchTimestamp("batch-1234"))
|
||||
assertNull(trash.batchTimestamp("holiday-photos"))
|
||||
assertNull(trash.batchTimestamp("batch-not-a-number"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the trash folder is hidden, the way Android hides dotfiles`() {
|
||||
assertTrue(Trash.TRASH_DIR_NAME.startsWith("."))
|
||||
assertEquals(volume.child(Trash.TRASH_DIR_NAME), trash(storage()).root())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TRASHED_AT = 1_700_000_000_000L
|
||||
}
|
||||
}
|
||||
@@ -440,4 +440,85 @@ class BrowserViewModelTest {
|
||||
}
|
||||
assertTrue(viewModel(savedState = saved).state.value.isRootView)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- undo
|
||||
|
||||
@Test
|
||||
fun `a delete only moves the item aside, so it can be taken back`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
|
||||
model.delete(listOf(downloads.child("a.txt")))
|
||||
|
||||
assertFalse(storage.contains(downloads.child("a.txt")))
|
||||
assertTrue(model.state.value.undo is Undo.Trashed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undoing a delete puts the file back`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
model.delete(listOf(downloads.child("a.txt")))
|
||||
|
||||
model.undo()
|
||||
|
||||
assertTrue(storage.contains(downloads.child("a.txt")))
|
||||
assertEquals("first", storage.contentOf(downloads.child("a.txt")))
|
||||
assertNull(model.state.value.undo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undoing a rename puts the old name back`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
model.rename(downloads.child("a.txt"), "renamed.txt")
|
||||
assertTrue(storage.contains(downloads.child("renamed.txt")))
|
||||
|
||||
model.undo()
|
||||
|
||||
assertTrue(storage.contains(downloads.child("a.txt")))
|
||||
assertFalse(storage.contains(downloads.child("renamed.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undoing a cut-paste moves the item back`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = true)
|
||||
model.openDirectory(documents)
|
||||
model.paste()
|
||||
assertTrue(storage.contains(documents.child("a.txt")))
|
||||
|
||||
model.undo()
|
||||
|
||||
assertTrue(storage.contains(downloads.child("a.txt")))
|
||||
assertFalse(storage.contains(documents.child("a.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy is not undoable, because nothing was changed`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(root)
|
||||
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
|
||||
model.openDirectory(documents)
|
||||
|
||||
model.paste()
|
||||
|
||||
assertNull(model.state.value.undo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the undo handle is cleared once it has been used`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
model.rename(downloads.child("a.txt"), "renamed.txt")
|
||||
|
||||
model.undo()
|
||||
assertNull(model.state.value.undo)
|
||||
|
||||
// A second tap on the same snackbar must not replay it.
|
||||
model.undo()
|
||||
assertTrue(storage.contains(downloads.child("a.txt")))
|
||||
assertFalse(storage.contains(downloads.child("renamed.txt")))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user