Remember what is in the trash, and add a screen to restore from it
The staged-delete work left a hole. The undo handle lived in BrowserUiState, so a delete from an earlier session was unreachable even though the files were sitting on the volume; and because nothing on disk recorded which folder an item came from, not even the user could put one back by hand with confidence. Adding a Trash screen alone would not have fixed it: the recovery data did not survive the process. So the origin is now written at delete time. data/ gains its first persistence: a SQLite index (staged path, original path, name, type, size, timestamp). SQLite rather than Room because an annotation processor is a large addition to this build for one table and three queries, and behind a TrashIndex port so domain/Trash keeps no Context while the tests drive it with a map and the real one is still tested against real SQLite. New Trash entry in the overflow menu lists batches and restores a whole one. The index is an index, not the truth: the staged files are the truth, and the two can disagree if a user empties the folder by hand or pulls a card. Reconciled both ways - restorableFrom drops entries whose file has gone, purgeExpired forgets entries whose folder has gone and sweeps folders no entry mentions - and rows are written after the move, so a crash leaves an unindexed folder (harmless, swept) rather than an entry that could never restore. Verified on hardware: delete, force-stop, relaunch, restore. Also corrects the docs where they claimed input swipe x y x y 900 is delivered as a plain tap on MIUI. It does open the selection bar - it was driven that way - but it has also landed as a tap, so it is flaky rather than impossible.
This commit is contained in:
@@ -5,7 +5,9 @@ import de.softwareapp_hb.filemanager.data.ContentResolverIncomingSource
|
||||
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
|
||||
import de.softwareapp_hb.filemanager.data.IncomingSource
|
||||
import de.softwareapp_hb.filemanager.data.MediaStoreDeleter
|
||||
import de.softwareapp_hb.filemanager.data.SqliteTrashIndex
|
||||
import de.softwareapp_hb.filemanager.data.StorageProvider
|
||||
import de.softwareapp_hb.filemanager.data.TrashIndex
|
||||
import de.softwareapp_hb.filemanager.domain.OperationNotifier
|
||||
import de.softwareapp_hb.filemanager.service.ServiceOperationNotifier
|
||||
|
||||
@@ -34,11 +36,16 @@ class FileManagerApplication : Application() {
|
||||
lateinit var incoming: IncomingSource
|
||||
private set
|
||||
|
||||
/** Remembers what is in the trash, and where each item came from. */
|
||||
lateinit var trashIndex: TrashIndex
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
storage = StorageProvider(this)
|
||||
notifier = ServiceOperationNotifier(this)
|
||||
deleteRequests = MediaStoreDeleter(this).factory()
|
||||
incoming = ContentResolverIncomingSource(contentResolver)
|
||||
trashIndex = SqliteTrashIndex(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.softwareapp_hb.filemanager.data
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The trash index, on SQLite.
|
||||
*
|
||||
* Deliberately **not** Room. Room would mean adding an annotation processor to a build that
|
||||
* has none, for one table and three queries; the platform's own SQLite needs no dependency
|
||||
* at all, and Robolectric runs it on the JVM, so this stays as testable as the rest of the app.
|
||||
*
|
||||
* If a general app database ever arrives — favourites, recents — Room becomes the better
|
||||
* foundation and that is a decision worth taking on its own.
|
||||
*/
|
||||
class SqliteTrashIndex(context: Context) : TrashIndex {
|
||||
|
||||
private val helper = Helper(context.applicationContext)
|
||||
|
||||
override suspend fun record(records: List<TrashRecord>) = withContext(Dispatchers.IO) {
|
||||
if (records.isEmpty()) return@withContext
|
||||
val db = helper.writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for (record in records) {
|
||||
db.insertWithOnConflict(TABLE, null, record.toValues(), SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun records(): List<TrashRecord> = withContext(Dispatchers.IO) {
|
||||
// Newest batch first and stable inside a batch, so a trash screen reads in the order
|
||||
// things were deleted rather than in whatever order the storage returns.
|
||||
helper.readableDatabase.query(
|
||||
TABLE,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"$COLUMN_STAGED_AT DESC, $COLUMN_BATCH_ID DESC, $COLUMN_STAGED_PATH ASC",
|
||||
).use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) add(cursor.toRecord())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun forget(batchId: String) = withContext(Dispatchers.IO) {
|
||||
helper.writableDatabase.delete(TABLE, "$COLUMN_BATCH_ID = ?", arrayOf(batchId))
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun TrashRecord.toValues(): ContentValues = ContentValues().apply {
|
||||
put(COLUMN_STAGED_PATH, stagedPath.value)
|
||||
put(COLUMN_BATCH_ID, batchId)
|
||||
put(COLUMN_BATCH_FOLDER, batchFolder.value)
|
||||
put(COLUMN_ORIGINAL_PATH, originalPath.value)
|
||||
put(COLUMN_NAME, name)
|
||||
put(COLUMN_IS_DIRECTORY, if (isDirectory) 1 else 0)
|
||||
put(COLUMN_SIZE_BYTES, sizeBytes)
|
||||
put(COLUMN_STAGED_AT, stagedAtMillis)
|
||||
}
|
||||
|
||||
private fun Cursor.toRecord(): TrashRecord = TrashRecord(
|
||||
batchId = getString(getColumnIndexOrThrow(COLUMN_BATCH_ID)),
|
||||
batchFolder = StoragePath.of(getString(getColumnIndexOrThrow(COLUMN_BATCH_FOLDER))),
|
||||
originalPath = StoragePath.of(getString(getColumnIndexOrThrow(COLUMN_ORIGINAL_PATH))),
|
||||
stagedPath = StoragePath.of(getString(getColumnIndexOrThrow(COLUMN_STAGED_PATH))),
|
||||
name = getString(getColumnIndexOrThrow(COLUMN_NAME)),
|
||||
isDirectory = getInt(getColumnIndexOrThrow(COLUMN_IS_DIRECTORY)) != 0,
|
||||
sizeBytes = getLong(getColumnIndexOrThrow(COLUMN_SIZE_BYTES)),
|
||||
stagedAtMillis = getLong(getColumnIndexOrThrow(COLUMN_STAGED_AT)),
|
||||
)
|
||||
|
||||
private class Helper(context: Context) :
|
||||
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE $TABLE (
|
||||
$COLUMN_STAGED_PATH TEXT NOT NULL PRIMARY KEY,
|
||||
$COLUMN_BATCH_ID TEXT NOT NULL,
|
||||
$COLUMN_BATCH_FOLDER TEXT NOT NULL,
|
||||
$COLUMN_ORIGINAL_PATH TEXT NOT NULL,
|
||||
$COLUMN_NAME TEXT NOT NULL,
|
||||
$COLUMN_IS_DIRECTORY INTEGER NOT NULL,
|
||||
$COLUMN_SIZE_BYTES INTEGER NOT NULL,
|
||||
$COLUMN_STAGED_AT INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX trashed_batch ON $TABLE ($COLUMN_BATCH_ID)")
|
||||
db.execSQL("CREATE INDEX trashed_staged_at ON $TABLE ($COLUMN_STAGED_AT)")
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
// Dropping rather than migrating, which is only acceptable while nothing has
|
||||
// shipped: unlike every other kind of cache here, this one cannot be rebuilt
|
||||
// from the device, because the device never recorded where the items came from.
|
||||
// A released version needs a real migration.
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE")
|
||||
onCreate(db)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DATABASE_NAME = "trash-index.db"
|
||||
const val DATABASE_VERSION = 1
|
||||
const val TABLE = "trashed"
|
||||
const val COLUMN_STAGED_PATH = "staged_path"
|
||||
const val COLUMN_BATCH_ID = "batch_id"
|
||||
const val COLUMN_BATCH_FOLDER = "batch_folder"
|
||||
const val COLUMN_ORIGINAL_PATH = "original_path"
|
||||
const val COLUMN_NAME = "name"
|
||||
const val COLUMN_IS_DIRECTORY = "is_directory"
|
||||
const val COLUMN_SIZE_BYTES = "size_bytes"
|
||||
const val COLUMN_STAGED_AT = "staged_at"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.softwareapp_hb.filemanager.data
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
|
||||
/**
|
||||
* One staged delete: an item sitting in the trash and the path it came from.
|
||||
*
|
||||
* [sizeBytes] is the entry's own size, not a folder's recursive total — measuring a tree at
|
||||
* delete time would make deleting as slow as copying.
|
||||
*/
|
||||
data class TrashRecord(
|
||||
val batchId: String,
|
||||
val batchFolder: StoragePath,
|
||||
val originalPath: StoragePath,
|
||||
val stagedPath: StoragePath,
|
||||
val name: String,
|
||||
val isDirectory: Boolean,
|
||||
val sizeBytes: Long,
|
||||
val stagedAtMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* The durable memory of what is in the trash.
|
||||
*
|
||||
* Without one, a staged delete is only recoverable for as long as the snackbar is on screen:
|
||||
* the files are still on disk, but nothing remembers which folder they came from, so not
|
||||
* even the user can put them back by hand with any confidence.
|
||||
*
|
||||
* **This is an index, not the truth.** The staged files are the truth, and the two can
|
||||
* disagree: a user can delete `.plainfiles-trash` themselves, an SD card can be removed, or
|
||||
* app data can be cleared while the folders stay. So a reader must drop records whose file
|
||||
* has gone, and a sweeper must clear folders no record mentions — see [Trash] for both.
|
||||
*
|
||||
* It lives in app-private storage, which is the point of having it rather than a manifest
|
||||
* beside the files: the index names folders all over the device, and that is nobody else's
|
||||
* business.
|
||||
*/
|
||||
interface TrashIndex {
|
||||
|
||||
/** Add [records], replacing any that describe the same staged path. */
|
||||
suspend fun record(records: List<TrashRecord>)
|
||||
|
||||
/** Every record, newest batch first. */
|
||||
suspend fun records(): List<TrashRecord>
|
||||
|
||||
/** Drop everything about one batch. */
|
||||
suspend fun forget(batchId: String)
|
||||
|
||||
/**
|
||||
* No index. The trash still stages and still sweeps, it just cannot offer anything back
|
||||
* after the process that deleted it is gone — which is the default for a `Trash` built
|
||||
* without one, and what the tests that do not care about persistence use.
|
||||
*/
|
||||
object None : TrashIndex {
|
||||
override suspend fun record(records: List<TrashRecord>) = Unit
|
||||
|
||||
override suspend fun records(): List<TrashRecord> = emptyList()
|
||||
|
||||
override suspend fun forget(batchId: String) = Unit
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ 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 de.softwareapp_hb.filemanager.data.TrashIndex
|
||||
import de.softwareapp_hb.filemanager.data.TrashRecord
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -11,7 +13,12 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
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>)
|
||||
data class TrashBatch(
|
||||
val folder: StoragePath,
|
||||
val entries: List<TrashedEntry>,
|
||||
/** When it was staged, so a trash screen can say how long ago. */
|
||||
val stagedAtMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* A staging folder that makes a delete undoable.
|
||||
@@ -33,12 +40,18 @@ data class TrashBatch(val folder: StoragePath, val entries: List<TrashedEntry>)
|
||||
* - **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.
|
||||
* - **Where each item came from is remembered in a [TrashIndex]**, not on the volume. The
|
||||
* staged files are the truth and the index is only an index, so the two are reconciled in
|
||||
* both directions: [restorable] drops rows whose file has gone, and [purgeExpired] sweeps
|
||||
* folders no row mentions.
|
||||
* - **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,
|
||||
/** Remembers where each staged item came from; [TrashIndex.None] forgets it again. */
|
||||
private val index: TrashIndex = TrashIndex.None,
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
|
||||
@@ -59,7 +72,8 @@ class Trash(
|
||||
return null to OperationResult(OperationKind.DELETE, 0, emptyList(), false)
|
||||
}
|
||||
|
||||
val batch = root().child(BATCH_PREFIX + clock())
|
||||
val stagedAt = clock()
|
||||
val batch = root().child(BATCH_PREFIX + stagedAt)
|
||||
val operations = FileOperations(source)
|
||||
val staged = ArrayList<TrashedEntry>()
|
||||
val failures = ArrayList<OperationFailure>()
|
||||
@@ -101,7 +115,28 @@ class Trash(
|
||||
}
|
||||
|
||||
val outcome = OperationResult(OperationKind.DELETE, staged.size, failures, cancelled)
|
||||
return batch.takeIf { staged.isNotEmpty() }?.let { TrashBatch(it, staged) } to outcome
|
||||
if (staged.isEmpty()) return null to outcome
|
||||
|
||||
// Recorded *after* the move, never before: a row is only worth having next to a file
|
||||
// that is really there. A crash between the two leaves a folder nothing indexes,
|
||||
// which [purgeExpired] sweeps; the other order would leave a row that can never
|
||||
// restore anything.
|
||||
index.record(
|
||||
staged.map { entry ->
|
||||
val stat = source.stat(entry.staged)
|
||||
TrashRecord(
|
||||
batchId = batch.name,
|
||||
batchFolder = batch,
|
||||
originalPath = entry.original,
|
||||
stagedPath = entry.staged,
|
||||
name = entry.original.name,
|
||||
isDirectory = stat?.isDirectory ?: false,
|
||||
sizeBytes = stat?.sizeBytes ?: 0L,
|
||||
stagedAtMillis = stagedAt,
|
||||
)
|
||||
},
|
||||
)
|
||||
return TrashBatch(batch, staged, stagedAt) to outcome
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,24 +179,49 @@ class Trash(
|
||||
}
|
||||
}
|
||||
|
||||
// Forgotten only once everything is accounted for: a partial restore keeps its rows
|
||||
// so a second attempt still knows what it was trying to put back.
|
||||
if (failures.isEmpty()) index.forget(batch.folder.name)
|
||||
|
||||
return OperationResult(OperationKind.MOVE, restored, failures, false)
|
||||
}
|
||||
|
||||
/** Remove batches older than [RETENTION_HOURS]. Returns how many were dropped. */
|
||||
suspend fun purgeExpired(): Int {
|
||||
// Rows whose batch folder has gone are dead weight — the user emptied the trash by
|
||||
// hand, or a card was pulled. Nothing can restore them, so let them go first.
|
||||
for (record in index.records()) {
|
||||
if (!source.exists(record.batchFolder)) index.forget(record.batchId)
|
||||
}
|
||||
|
||||
val root = root()
|
||||
if (!source.exists(root)) return 0
|
||||
|
||||
val expiry = clock() - RETENTION_MILLIS
|
||||
var purged = 0
|
||||
for (child in source.list(root)) {
|
||||
// The age lives in the folder name, so a folder no row mentions is still swept.
|
||||
// That is the reconciliation for a crash between the move and the index write.
|
||||
val stagedAt = batchTimestamp(child.name) ?: continue
|
||||
if (stagedAt >= expiry) continue
|
||||
if (source.delete(child.path)) purged++
|
||||
if (source.delete(child.path)) {
|
||||
index.forget(child.name)
|
||||
purged++
|
||||
}
|
||||
}
|
||||
return purged
|
||||
}
|
||||
|
||||
/**
|
||||
* What the index says is still recoverable on this volume, newest batch first.
|
||||
*
|
||||
* Records whose staged file has gone are skipped as they are found: the index is an
|
||||
* index, and a row that no longer describes a real file cannot restore anything. This is
|
||||
* what makes a delete reversible after the app has been restarted — the snackbar handle
|
||||
* never could, because it only ever lived in memory.
|
||||
*/
|
||||
suspend fun restorable(): List<TrashBatch> = restorableFrom(source, index)
|
||||
|
||||
/**
|
||||
* The epoch millis a batch folder was created, or `null` when the name is not one of
|
||||
* ours.
|
||||
@@ -182,8 +242,36 @@ class 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)
|
||||
fun of(source: StorageSource, batch: TrashBatch, index: TrashIndex = TrashIndex.None): Trash =
|
||||
Trash(source, batch.folder.parent?.parent ?: StoragePath.Root, index)
|
||||
|
||||
/**
|
||||
* Everything [index] can still restore, newest batch first.
|
||||
*
|
||||
* No `Trash` instance is needed because reading does not care which volume a batch
|
||||
* sits on: every record already carries its own folder. Asking per volume would
|
||||
* return the same batches once per volume.
|
||||
*/
|
||||
suspend fun restorableFrom(source: StorageSource, index: TrashIndex): List<TrashBatch> {
|
||||
val grouped = LinkedHashMap<String, Group>()
|
||||
for (record in index.records()) {
|
||||
if (!source.exists(record.stagedPath)) continue
|
||||
val group = grouped.getOrPut(record.batchId) {
|
||||
Group(record.batchFolder, record.stagedAtMillis, mutableListOf())
|
||||
}
|
||||
group.entries += TrashedEntry(
|
||||
original = record.originalPath,
|
||||
staged = record.stagedPath,
|
||||
)
|
||||
}
|
||||
return grouped.values.map { TrashBatch(it.folder, it.entries, it.stagedAtMillis) }
|
||||
}
|
||||
|
||||
private class Group(
|
||||
val folder: StoragePath,
|
||||
val stagedAtMillis: Long,
|
||||
val entries: MutableList<TrashedEntry>,
|
||||
)
|
||||
|
||||
private const val BATCH_PREFIX = "batch-"
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.ui.trash.TrashScreen
|
||||
import de.softwareapp_hb.filemanager.ui.usage.UsageScreen
|
||||
import de.softwareapp_hb.filemanager.ui.viewer.ImageViewerScreen
|
||||
import de.softwareapp_hb.filemanager.ui.viewer.TextEditorScreen
|
||||
@@ -189,6 +190,16 @@ fun BrowserScreen(
|
||||
return
|
||||
}
|
||||
|
||||
is Overlay.Trash -> {
|
||||
TrashScreen(
|
||||
batches = overlay.batches,
|
||||
loading = overlay.loading,
|
||||
onRestore = viewModel::restoreFromTrash,
|
||||
onClose = viewModel::closeOverlay,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Overlay.None -> Unit
|
||||
}
|
||||
|
||||
@@ -237,6 +248,7 @@ fun BrowserScreen(
|
||||
onPaste = viewModel::paste,
|
||||
onRefresh = viewModel::refresh,
|
||||
onUsage = viewModel::openUsage,
|
||||
onTrash = viewModel::openTrash,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PieChart
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
@@ -46,6 +47,7 @@ fun BrowserTopBar(
|
||||
onPaste: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onUsage: () -> Unit,
|
||||
onTrash: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -109,6 +111,11 @@ fun BrowserTopBar(
|
||||
leadingIcon = { Icon(Icons.Filled.PieChart, contentDescription = null) },
|
||||
onClick = { menuOpen = false; onUsage() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.browser_action_trash)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
|
||||
onClick = { menuOpen = false; onTrash() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.browser_action_refresh)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
|
||||
|
||||
@@ -102,6 +102,17 @@ sealed interface Overlay {
|
||||
val nodes: List<UsageNode>,
|
||||
val loading: Boolean,
|
||||
) : Overlay
|
||||
|
||||
/**
|
||||
* What the trash still holds, rebuilt from the index rather than from memory.
|
||||
*
|
||||
* The snackbar's undo handle dies with the process, so this is the only way back to a
|
||||
* delete from earlier.
|
||||
*/
|
||||
data class Trash(
|
||||
val batches: List<TrashBatch>,
|
||||
val loading: Boolean,
|
||||
) : Overlay
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ import de.softwareapp_hb.filemanager.data.IncomingSource
|
||||
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.data.TrashIndex
|
||||
import de.softwareapp_hb.filemanager.domain.ArchiveEngine
|
||||
import de.softwareapp_hb.filemanager.domain.FileOperations
|
||||
import de.softwareapp_hb.filemanager.domain.FileSearch
|
||||
@@ -34,6 +35,7 @@ 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.TrashBatch
|
||||
import de.softwareapp_hb.filemanager.domain.UsageAnalysis
|
||||
import de.softwareapp_hb.filemanager.domain.UsageNode
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -67,6 +69,7 @@ class BrowserViewModel(
|
||||
private val deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null },
|
||||
private val savedState: SavedStateHandle = SavedStateHandle(),
|
||||
private val incoming: IncomingSource? = null,
|
||||
private val trashIndex: TrashIndex = TrashIndex.None,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(restoredViewState())
|
||||
@@ -466,6 +469,61 @@ class BrowserViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show what the trash still holds.
|
||||
*
|
||||
* Rebuilt from the index, so it includes deletes made in earlier sessions — which is the
|
||||
* whole difference between this and the snackbar's undo, whose handle died with the
|
||||
* process that made it.
|
||||
*/
|
||||
fun openTrash() {
|
||||
val source = storage.activeSource() ?: return
|
||||
_state.update { it.copy(overlay = Overlay.Trash(emptyList(), loading = true)) }
|
||||
|
||||
viewModelScope.launch {
|
||||
val batches = runCatching { Trash.restorableFrom(source, trashIndex) }
|
||||
.getOrDefault(emptyList())
|
||||
_state.update { current ->
|
||||
val overlay = current.overlay as? Overlay.Trash ?: return@update current
|
||||
current.copy(overlay = overlay.copy(batches = batches, loading = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put one batch back where it came from, then re-read the trash.
|
||||
*
|
||||
* A name taken again in the meantime is not overwritten — see [Trash.restore] — so the
|
||||
* item comes back beside whatever holds the name now.
|
||||
*/
|
||||
fun restoreFromTrash(batch: TrashBatch) {
|
||||
val source = storage.activeSource() ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true) }
|
||||
notifier.start(OperationKind.MOVE)
|
||||
val result = try {
|
||||
Trash.of(source, batch, trashIndex).restore(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)
|
||||
}
|
||||
|
||||
// Re-read rather than dropping the batch locally: the truth is the index *and*
|
||||
// the filesystem, and a partial restore changes both.
|
||||
openTrash()
|
||||
_state.value.path?.let { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun closeOverlay() {
|
||||
_state.update { it.copy(overlay = Overlay.None) }
|
||||
}
|
||||
@@ -717,7 +775,9 @@ class BrowserViewModel(
|
||||
is Undo.Relocate -> relocateBack(source, pending.moves)
|
||||
|
||||
is Undo.Trashed ->
|
||||
Trash.of(source, pending.batch).restore(pending.batch) { notifier.update(it) }
|
||||
Trash.of(source, pending.batch, trashIndex).restore(pending.batch) {
|
||||
notifier.update(it)
|
||||
}
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
@@ -849,7 +909,7 @@ class BrowserViewModel(
|
||||
*/
|
||||
private suspend fun deleteDirectly(paths: List<StoragePath>) {
|
||||
val source = storage.activeSource() ?: return
|
||||
val trash = Trash(source, volumeRootFor(paths))
|
||||
val trash = Trash(source, volumeRootFor(paths), trashIndex)
|
||||
|
||||
_state.update { it.copy(loading = true, selection = emptySet()) }
|
||||
notifier.start(OperationKind.DELETE)
|
||||
@@ -987,6 +1047,7 @@ class BrowserViewModel(
|
||||
deleteRequests = app.deleteRequests,
|
||||
savedState = createSavedStateHandle(),
|
||||
incoming = app.incoming,
|
||||
trashIndex = app.trashIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.softwareapp_hb.filemanager.ui.trash
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.core.Timestamps
|
||||
import de.softwareapp_hb.filemanager.domain.TrashBatch
|
||||
import de.softwareapp_hb.filemanager.ui.browser.EmptyState
|
||||
import de.softwareapp_hb.filemanager.ui.browser.itemCountLabel
|
||||
|
||||
/**
|
||||
* What the trash still holds, and the way to put it back.
|
||||
*
|
||||
* A row is one *batch*, not one file: a batch is what a single delete did, which is the unit
|
||||
* the user performed and the unit the index records. Restoring a batch is therefore also the
|
||||
* unit that makes sense — "put back what I deleted at 14:20", not "resurrect one file".
|
||||
*
|
||||
* This is the screen the snackbar could never be. Its undo handle lived in memory, so a
|
||||
* delete from a previous session was unreachable even though the files were sitting right
|
||||
* there on the volume.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrashScreen(
|
||||
batches: List<TrashBatch>,
|
||||
loading: Boolean,
|
||||
onRestore: (TrashBatch) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.trash_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.browser_action_close),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
if (batches.isEmpty() && !loading) {
|
||||
EmptyState(
|
||||
message = stringResource(R.string.trash_empty),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = batches, key = { it.folder.value }) { batch ->
|
||||
TrashRow(batch = batch, onRestore = { onRestore(batch) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrashRow(batch: TrashBatch, onRestore: () -> Unit) {
|
||||
ListItem(
|
||||
headlineContent = { Text(itemCountLabel(batch.entries.size)) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
Timestamps.format(batch.stagedAtMillis).takeIf { it.isNotEmpty() },
|
||||
batch.entries.joinToString(", ") { it.original.name },
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(onClick = onRestore) {
|
||||
Text(stringResource(R.string.trash_restore))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -64,6 +64,12 @@
|
||||
<string name="incoming_save_action">Save here</string>
|
||||
<string name="incoming_pick_hint">Navigate to a file and tap it to hand it back</string>
|
||||
<string name="incoming_cancel">Cancel</string>
|
||||
|
||||
<!-- ===== Trash ===== -->
|
||||
<string name="trash_title">Trash</string>
|
||||
<string name="trash_empty">Nothing in the trash</string>
|
||||
<string name="trash_restore">Restore</string>
|
||||
<string name="browser_action_trash">Trash</string>
|
||||
<string name="browser_action_more">More options</string>
|
||||
<string name="browser_action_refresh">Refresh</string>
|
||||
<string name="browser_action_cancel">Cancel</string>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.softwareapp_hb.filemanager.data
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The index is the only thing that makes a staged delete reversible after a restart, so it is
|
||||
* worth proving against real SQLite rather than only against a map. Ordering and
|
||||
* replace-on-conflict are exactly where a fake would paper over a mistake.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class SqliteTrashIndexTest {
|
||||
|
||||
private val batch = StoragePath.of("/storage/emulated/0/.plainfiles-trash/batch-1000")
|
||||
|
||||
private fun index() = SqliteTrashIndex(RuntimeEnvironment.getApplication())
|
||||
|
||||
private fun record(
|
||||
name: String,
|
||||
stagedAt: Long = 1000L,
|
||||
batchId: String = "batch-1000",
|
||||
) = TrashRecord(
|
||||
batchId = batchId,
|
||||
batchFolder = batch,
|
||||
originalPath = StoragePath.of("/storage/emulated/0/Documents/$name"),
|
||||
stagedPath = batch.child("0").child(name),
|
||||
name = name,
|
||||
isDirectory = false,
|
||||
sizeBytes = 42L,
|
||||
stagedAtMillis = stagedAt,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a recorded item reads back whole`() = runBlocking {
|
||||
val index = index()
|
||||
index.record(listOf(record("report.pdf")))
|
||||
|
||||
val row = index.records().single()
|
||||
|
||||
assertEquals("report.pdf", row.name)
|
||||
assertEquals("/storage/emulated/0/Documents/report.pdf", row.originalPath.value)
|
||||
assertEquals(batch, row.batchFolder)
|
||||
assertEquals(42L, row.sizeBytes)
|
||||
assertEquals(1000L, row.stagedAtMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recording the same staged path twice replaces instead of duplicating`() = runBlocking {
|
||||
val index = index()
|
||||
index.record(listOf(record("a.txt")))
|
||||
index.record(listOf(record("a.txt")))
|
||||
|
||||
assertEquals(1, index.records().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records come back newest batch first`() = runBlocking {
|
||||
val index = index()
|
||||
index.record(listOf(record("old.txt", stagedAt = 1000L, batchId = "batch-1000")))
|
||||
index.record(listOf(record("new.txt", stagedAt = 2000L, batchId = "batch-2000")))
|
||||
|
||||
assertEquals(listOf("new.txt", "old.txt"), index.records().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forgetting one batch leaves the others alone`() = runBlocking {
|
||||
val index = index()
|
||||
index.record(listOf(record("a.txt", stagedAt = 1000L, batchId = "batch-1000")))
|
||||
index.record(listOf(record("b.txt", stagedAt = 2000L, batchId = "batch-2000")))
|
||||
|
||||
index.forget("batch-1000")
|
||||
|
||||
assertEquals(listOf("b.txt"), index.records().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty index reads back empty rather than throwing`() = runBlocking {
|
||||
assertTrue(index().records().isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.softwareapp_hb.filemanager.domain
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.data.TrashIndex
|
||||
import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
|
||||
import de.softwareapp_hb.filemanager.domain.testutil.InMemoryTrashIndex
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -28,8 +30,11 @@ class TrashTest {
|
||||
addFile(docs.child("report.pdf"), "report")
|
||||
}
|
||||
|
||||
private fun trash(source: InMemoryStorageSource, at: Long = TRASHED_AT) =
|
||||
Trash(source, volume, clock = { at })
|
||||
private fun trash(
|
||||
source: InMemoryStorageSource,
|
||||
at: Long = TRASHED_AT,
|
||||
index: TrashIndex = TrashIndex.None,
|
||||
) = Trash(source, volume, index, clock = { at })
|
||||
|
||||
@Test
|
||||
fun `staging moves an item aside and records where it came from`() = runBlocking {
|
||||
@@ -153,6 +158,93 @@ class TrashTest {
|
||||
assertEquals(volume.child(Trash.TRASH_DIR_NAME), trash(storage()).root())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `staging records where the item came from`() = runBlocking {
|
||||
val storage = storage()
|
||||
val index = InMemoryTrashIndex()
|
||||
|
||||
trash(storage, index = index).stage(listOf(notes))
|
||||
|
||||
val row = index.records().single()
|
||||
assertEquals(notes, row.originalPath)
|
||||
assertEquals("notes.txt", row.name)
|
||||
assertFalse(row.isDirectory)
|
||||
assertEquals(5L, row.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a staged delete is still recoverable after the process forgets everything`() = runBlocking {
|
||||
// The point of the index. The snackbar's handle is in-memory only, so before this a
|
||||
// restart turned a staged delete into a folder nobody could place.
|
||||
val storage = storage()
|
||||
val index = InMemoryTrashIndex()
|
||||
trash(storage, index = index).stage(listOf(notes, docs))
|
||||
assertFalse(storage.contains(notes))
|
||||
|
||||
// A brand-new Trash, as a relaunched app would build: it shares only the index.
|
||||
val reopened = trash(storage, index = index)
|
||||
val batches = reopened.restorable()
|
||||
assertEquals(1, batches.size)
|
||||
|
||||
val result = reopened.restore(batches.single())
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertTrue(storage.contains(notes))
|
||||
assertTrue(storage.contains(docs))
|
||||
assertEquals("hello", storage.contentOf(notes))
|
||||
assertEquals("report", storage.contentOf(docs.child("report.pdf")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the index is emptied once a restore has succeeded`() = runBlocking {
|
||||
val storage = storage()
|
||||
val index = InMemoryTrashIndex()
|
||||
val (batch, _) = trash(storage, index = index).stage(listOf(notes))
|
||||
|
||||
trash(storage, index = index).restore(batch!!)
|
||||
|
||||
assertTrue(index.records().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row whose file has gone is not offered back`() = runBlocking {
|
||||
val storage = storage()
|
||||
val index = InMemoryTrashIndex()
|
||||
trash(storage, index = index).stage(listOf(notes))
|
||||
// Someone emptied the folder by hand, or a card was pulled out.
|
||||
storage.delete(index.records().single().stagedPath)
|
||||
|
||||
assertTrue(trash(storage, index = index).restorable().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purging forgets the rows it deletes`() = runBlocking {
|
||||
val storage = storage()
|
||||
val index = InMemoryTrashIndex()
|
||||
val (old, _) = trash(storage, at = 0L, index = index).stage(listOf(notes))
|
||||
trash(storage, at = 0L, index = index).restore(old!!)
|
||||
val (fresh, _) = trash(storage, at = Trash.RETENTION_MILLIS + 1, index = index)
|
||||
.stage(listOf(notes))
|
||||
|
||||
trash(storage, at = Trash.RETENTION_MILLIS + 2, index = index).purgeExpired()
|
||||
|
||||
val left = index.records()
|
||||
assertEquals(1, left.size)
|
||||
assertEquals(fresh!!.folder.name, left.single().batchId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purging drops rows whose folder has gone, so the index cannot grow forever`() = runBlocking {
|
||||
val storage = storage()
|
||||
val index = InMemoryTrashIndex()
|
||||
val (batch, _) = trash(storage, index = index).stage(listOf(notes))
|
||||
storage.delete(batch!!.folder)
|
||||
|
||||
trash(storage, index = index).purgeExpired()
|
||||
|
||||
assertTrue(index.records().isEmpty())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TRASHED_AT = 1_700_000_000_000L
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.softwareapp_hb.filemanager.domain.testutil
|
||||
|
||||
import de.softwareapp_hb.filemanager.data.TrashIndex
|
||||
import de.softwareapp_hb.filemanager.data.TrashRecord
|
||||
|
||||
/**
|
||||
* A [TrashIndex] in a map, for exercising the trash without a database.
|
||||
*
|
||||
* Ordered the way [de.softwareapp_hb.filemanager.data.SqliteTrashIndex] orders, so a test
|
||||
* that passes here says something about the real one.
|
||||
*/
|
||||
class InMemoryTrashIndex : TrashIndex {
|
||||
|
||||
private val rows = LinkedHashMap<String, TrashRecord>()
|
||||
|
||||
override suspend fun record(records: List<TrashRecord>) {
|
||||
for (record in records) rows[record.stagedPath.value] = record
|
||||
}
|
||||
|
||||
override suspend fun records(): List<TrashRecord> = rows.values.sortedWith(
|
||||
compareByDescending<TrashRecord> { it.stagedAtMillis }.thenBy { it.stagedPath.value },
|
||||
)
|
||||
|
||||
override suspend fun forget(batchId: String) {
|
||||
rows.filterValues { it.batchId == batchId }.keys.toList().forEach(rows::remove)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user