Four milestones from the survey's remaining gaps

M17 - the mark of a change. A dot on the rows an operation produced, spent by the
next read of that folder: in memory, per folder, no timer and no watcher. Copy,
move, compress, extract, rename (single and batch) and new folders all mark what
they made, so "what did that 4 GB copy do?" has an answer even when the copy went
to a folder the user was not looking at. The dot is drawn by MarkOfChange in the
list and the grid, with a content description, and `changed` is set by the same
state update that announces the folder.

M18 - store-only archives. Compressing now asks for the name and whether to
compress, because the survey's most enthusiastic review is a bulk copy to a
computer and that file is better stored than squeezed. Deflater.NO_COMPRESSION
keeps it one pass; a STORED entry would need size and CRC before the header,
i.e. reading every file twice.

M19 - the trash says what it does. The screen states the 24-hour window, offers
Empty trash behind one question, and opening the trash sweeps what has expired -
the window used to run only after the next delete, so an old batch could stay on
the card indefinitely. Trash.emptyAll leaves anything in that folder that is not
one of ours alone.

M20 - launcher shortcuts. Images, Recent and Trash as static shortcuts carrying
a route extra; core/ShortcutRoute answers null for anything it does not know, so
an old shortcut cannot crash a new build. Favourites was dropped from the plan:
it is a section of the home screen, not a view, so a shortcut to it would promise
something that may not be there.

M21 (Android TV) is specified in the roadmap and deliberately not started: its
acceptance needs a TV device pass this machine cannot provide.

18 new tests (427 in total). Detekt's baseline was regenerated after two
signatures gained a parameter; the diff is three findings with new signatures and
their KDoc moved above the annotations so the baseline stays readable. Verified by
the gate only so far - the device pass for these four is next, and the terminal
here has been losing command output for the last hour.
This commit is contained in:
2026-10-18 00:15:03 +02:00
parent 17078bf613
commit 5d39f4b3fb
23 changed files with 924 additions and 38 deletions
+18
View File
@@ -12,6 +12,24 @@ upload needs a **higher `versionCode`** than every previous upload.
### Added ### Added
- **A dot marks what an operation just produced.** Copy something, extract an archive, rename a file or make a
folder, and the rows it created carry a small dot until you open that folder again — so "what did that 4 GB
copy do?" has an answer, including when the copy went to a folder you were not looking at. The mark lives in
memory and is spent by the next read of the folder: no timer, no watcher, nothing to switch off. It carries a
content description, so TalkBack says it too.
- **Compressing asks two things: the name, and whether to compress.** The second one is for the case the
survey's most enthusiastic review describes — moving a whole collection to a computer: off stores the files
as they are, so the archive is built in one pass instead of being squeezed first. It is still an ordinary
zip, and it still extracts to the same bytes.
- **The trash says how long it keeps things, and empties on request.** The screen now states the window
("Deleted files are kept for 24 hours") instead of leaving it to be discovered, **Empty trash** does what it
says after one question, and opening the trash sweeps what has expired — the 24-hour window used to run only
when you deleted something else, so an old batch could sit on the card indefinitely.
- **Long-press the app icon: Images, Recent, Trash.** Three shortcuts to the views that are otherwise two or
three taps deep, with no permission and no dynamic state. Favourites is deliberately not one: it is a
section of the home screen rather than a view, so a shortcut to it would promise something that may not be
there.
- **Copy to… and Move to… chose a destination instead of making you go there.** The selection bar's - **Copy to… and Move to… chose a destination instead of making you go there.** The selection bar's
overflow now offers both verbs: a folder picker opens — the volumes first, then folders one level at a overflow now offers both verbs: a folder picker opens — the volumes first, then folders one level at a
time, with the trail above and Up beside it — and confirming starts the copy (or the move) into that time, with the trail above and Up beside it — and confirming starts the copy (or the move) into that
+5
View File
@@ -74,6 +74,11 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Long-press shortcuts: Images, Recent, Trash. See res/xml/shortcuts.xml. -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<!-- <!--
Receiving a share. Without these the app is not a share target at all — the Receiving a share. Without these the app is not a share target at all — the
manifest had nothing but LAUNCHER, so no other app could hand it a file. manifest had nothing but LAUNCHER, so no other app could hand it a file.
@@ -10,6 +10,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.ShortcutRoute
import de.softwareapp_hb.filemanager.ui.browser.BrowserScreen import de.softwareapp_hb.filemanager.ui.browser.BrowserScreen
import de.softwareapp_hb.filemanager.ui.browser.IncomingRequest import de.softwareapp_hb.filemanager.ui.browser.IncomingRequest
import de.softwareapp_hb.filemanager.ui.browser.contentUriFor import de.softwareapp_hb.filemanager.ui.browser.contentUriFor
@@ -25,6 +26,8 @@ class MainActivity : ComponentActivity() {
// has neither, and the browser then behaves exactly as it always did — the two // has neither, and the browser then behaves exactly as it always did — the two
// flows are the same screen with one extra bar on it. // flows are the same screen with one extra bar on it.
val incoming = IncomingRequest.from(intent) val incoming = IncomingRequest.from(intent)
// A launcher shortcut carries a route instead: which view to open, once.
val route = ShortcutRoute.from(intent?.getStringExtra(ShortcutRoute.EXTRA))
setContent { setContent {
FileManagerTheme { FileManagerTheme {
@@ -34,6 +37,7 @@ class MainActivity : ComponentActivity() {
) { ) {
BrowserScreen( BrowserScreen(
incoming = incoming, incoming = incoming,
startRoute = route,
onDeliver = ::deliver, onDeliver = ::deliver,
onDone = ::finish, onDone = ::finish,
) )
@@ -0,0 +1,27 @@
package de.softwareapp_hb.filemanager.core
/**
* Where a launcher shortcut asks the app to open.
*
* The keys are what `res/xml/shortcuts.xml` puts in its intent's extra, and [from] answers `null` for
* anything else — an old shortcut surviving an upgrade, or a typo in the XML. A launcher entry must never
* be able to crash the app it opens.
*
* **Recent, not Favourites.** Favourites is a section on the home screen rather than a view of its own, so a
* shortcut to it would open the home screen and promise a section that is absent when nothing is starred.
* The three routes here all open a view that exists by name.
*/
enum class ShortcutRoute(val key: String) {
IMAGES("images"),
RECENT("recent"),
TRASH("trash"),
;
companion object {
/** The intent extra the shortcuts carry. */
const val EXTRA = "de.softwareapp_hb.filemanager.SHORTCUT"
fun from(key: String?): ShortcutRoute? = entries.firstOrNull { it.key == key }
}
}
@@ -25,6 +25,7 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.nio.channels.SeekableByteChannel import java.nio.channels.SeekableByteChannel
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.Deflater
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -69,10 +70,17 @@ data class ArchiveEntry(
*/ */
class ArchiveEngine(private val source: StorageSource) { class ArchiveEngine(private val source: StorageSource) {
/** Write [entries] into a new ZIP at [destination]. Folders are included recursively. */ /**
* Write [entries] into a new ZIP at [destination]. Folders are included recursively.
*
* [compress] `false` stores the files as they are: one pass, for an archive that is about to be copied off
* the phone. `setLevel` rather than STORED entries, which would need each file's size and CRC before its
* header — reading everything twice, slower than the deflate it avoids.
*/
suspend fun createZip( suspend fun createZip(
entries: List<StoragePath>, entries: List<StoragePath>,
destination: StoragePath, destination: StoragePath,
compress: Boolean = true,
onProgress: (OperationProgress) -> Unit = {}, onProgress: (OperationProgress) -> Unit = {},
): OperationResult { ): OperationResult {
val planned = LinkedHashMap<StoragePath, String>() val planned = LinkedHashMap<StoragePath, String>()
@@ -84,6 +92,7 @@ class ArchiveEngine(private val source: StorageSource) {
source.createFile(destination) source.createFile(destination)
source.openWrite(destination).use { raw -> source.openWrite(destination).use { raw ->
ZipOutputStream(BufferedOutputStream(raw)).use { zip -> ZipOutputStream(BufferedOutputStream(raw)).use { zip ->
zip.setLevel(if (compress) Deflater.DEFAULT_COMPRESSION else Deflater.NO_COMPRESSION)
for ((file, entryName) in planned) { for ((file, entryName) in planned) {
if (!currentCoroutineContext().isActive) { if (!currentCoroutineContext().isActive) {
state.cancel() state.cancel()
@@ -264,6 +264,28 @@ class Trash(
return purged return purged
} }
/**
* Delete every staged batch for good, now.
*
* The one destructive action in the app with no undo — which is what emptying means: it does what
* [purgeExpired] would do later, on purpose. Anything in the trash folder that is not one of our batches is
* left alone; the folder lives on the user's volume and is not ours to tidy.
*/
suspend fun emptyAll(): Int {
val root = root()
if (!source.exists(root)) return 0
var removed = 0
for (child in source.list(root)) {
if (batchTimestamp(child.name) == null) continue
if (source.delete(child.path)) {
index.forget(child.name)
removed++
}
}
return removed
}
/** /**
* What the index says is still recoverable on this volume, newest batch first. * What the index says is still recoverable on this volume, newest batch first.
* *
@@ -3,7 +3,9 @@ package de.softwareapp_hb.filemanager.ui.browser
import android.content.Context import android.content.Context
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -11,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android import androidx.compose.material.icons.filled.Android
@@ -43,6 +47,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -189,6 +195,24 @@ fun iconFor(category: FileCategory): ImageVector = when (category) {
FileCategory.RECENT -> Icons.Filled.History FileCategory.RECENT -> Icons.Filled.History
} }
/**
* The dot that says an operation produced this row.
*
* A dot rather than a colour: a row already has two states that use colour — selected, and focused in a
* two-pane window — and a third would make them hard to tell apart. The content description is what makes
* it more than decoration: TalkBack reads it, and a Compose test can find it.
*/
@Composable
fun MarkOfChange(modifier: Modifier = Modifier) {
val label = stringResource(R.string.browser_row_changed)
Box(
modifier = modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.semantics { contentDescription = label },
)
}
/** One row of the list view. */ /** One row of the list view. */
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -204,6 +228,8 @@ fun EntryRow(
* than opening them. * than opening them.
*/ */
selectionMode: Boolean = false, selectionMode: Boolean = false,
/** `true` when an operation produced this entry and the folder has not been read since. */
changed: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null,
@@ -224,7 +250,20 @@ fun EntryRow(
// `ListItem` measures the leading slot rather than capping it, so the row grows to // `ListItem` measures the leading slot rather than capping it, so the row grows to
// suit a 48 dp thumbnail instead of clipping it. // suit a 48 dp thumbnail instead of clipping it.
leadingContent = { EntryIcon(entry = entry, size = ListThumbnailSize) }, leadingContent = { EntryIcon(entry = entry, size = ListThumbnailSize) },
headlineContent = { Text(text = entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = entry.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (changed) {
Spacer(Modifier.width(8.dp))
MarkOfChange()
}
}
},
supportingContent = { supportingContent = {
val description = entry.subtitle() val description = entry.subtitle()
Text( Text(
@@ -262,6 +301,8 @@ fun EntryGridCell(
location: String? = null, location: String? = null,
/** `true` while a selection is running; see [EntryRow.selectionMode]. */ /** `true` while a selection is running; see [EntryRow.selectionMode]. */
selectionMode: Boolean = false, selectionMode: Boolean = false,
/** `true` when an operation produced this entry; see [EntryRow.changed]. */
changed: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null,
@@ -292,12 +333,19 @@ fun EntryGridCell(
} }
EntryIcon(entry = entry, size = GridThumbnailSize) EntryIcon(entry = entry, size = GridThumbnailSize)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = entry.name, text = entry.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
) )
if (changed) {
Spacer(Modifier.width(6.dp))
MarkOfChange()
}
}
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
val description = entry.subtitle() val description = entry.subtitle()
Text( Text(
@@ -346,6 +346,7 @@ private fun DirectoryView(
entry = entry, entry = entry,
selected = entry.isHighlighted(state), selected = entry.isHighlighted(state),
selectionMode = selecting, selectionMode = selecting,
changed = entry.path in state.changed,
location = entry.searchLocation(state.searchRoot), location = entry.searchLocation(state.searchRoot),
onClick = when { onClick = when {
selecting -> toggle selecting -> toggle
@@ -376,6 +377,7 @@ private fun DirectoryView(
entry = entry, entry = entry,
selected = entry.isHighlighted(state), selected = entry.isHighlighted(state),
selectionMode = selecting, selectionMode = selecting,
changed = entry.path in state.changed,
location = entry.searchLocation(state.searchRoot), location = entry.searchLocation(state.searchRoot),
onClick = when { onClick = when {
selecting -> toggle selecting -> toggle
@@ -23,6 +23,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.Switch
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -43,6 +44,64 @@ import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.isUsableName import de.softwareapp_hb.filemanager.core.isUsableName
/**
* Compressing something, with the one choice that changes what comes out.
*
* The name is the usual one — suggested from the selection, made unique against the folder. The switch is
* the interesting half: **compression is a trade**, and a file that is about to leave the phone is often
* better stored than squeezed. It starts on, because saving space is what "compress" means to most people;
* it is not remembered, because the next archive is a different decision.
*/
@Composable
fun CompressDialog(
initialName: String,
onConfirm: (name: String, compress: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember { mutableStateOf(initialName) }
var compress by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.compress_dialog_title)) },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.compress_dialog_name)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.compress_dialog_contents))
Text(
text = stringResource(R.string.compress_dialog_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = compress, onCheckedChange = { compress = it })
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(name, compress) },
enabled = name.isNotBlank(),
) {
Text(stringResource(R.string.compress_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.compress_dialog_cancel)) }
},
)
}
/** /**
* The **Copy to… / Move to…** picker. * The **Copy to… / Move to…** picker.
* *
@@ -67,7 +67,9 @@ import de.softwareapp_hb.filemanager.FileManagerApplication
import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.ConflictPolicy import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.ShortcutRoute
import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.domain.Trash
import de.softwareapp_hb.filemanager.text.labelRes import de.softwareapp_hb.filemanager.text.labelRes
import de.softwareapp_hb.filemanager.ui.duplicates.DuplicateScreen import de.softwareapp_hb.filemanager.ui.duplicates.DuplicateScreen
import de.softwareapp_hb.filemanager.ui.trash.TrashScreen import de.softwareapp_hb.filemanager.ui.trash.TrashScreen
@@ -80,10 +82,17 @@ import de.softwareapp_hb.filemanager.ui.viewer.MediaPlayerScreen
import de.softwareapp_hb.filemanager.ui.viewer.PdfViewerScreen import de.softwareapp_hb.filemanager.ui.viewer.PdfViewerScreen
import de.softwareapp_hb.filemanager.ui.viewer.TextEditorScreen import de.softwareapp_hb.filemanager.ui.viewer.TextEditorScreen
/**
* The browser, and everything that can be on top of it.
*
* [startRoute] is set when the app was opened from a launcher shortcut: the view is applied once, by the
* effect below, and `null` — every ordinary launch — leaves the screen exactly as it always was.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BrowserScreen( fun BrowserScreen(
incoming: IncomingRequest? = null, incoming: IncomingRequest? = null,
startRoute: ShortcutRoute? = null,
onDeliver: (FileEntry) -> Unit = {}, onDeliver: (FileEntry) -> Unit = {},
onDone: () -> Unit = {}, onDone: () -> Unit = {},
viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory), viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory),
@@ -93,6 +102,12 @@ fun BrowserScreen(
val provider = remember(context) { (context.applicationContext as FileManagerApplication).storage } val provider = remember(context) { (context.applicationContext as FileManagerApplication).storage }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// A shortcut is a request to open one view. Keyed on the route so a recomposition cannot repeat it, and
// `null` for every ordinary launch, which is the case that has to stay exactly as it was.
LaunchedEffect(startRoute) {
startRoute?.let(viewModel::openShortcut)
}
var showSort by remember { mutableStateOf(false) } var showSort by remember { mutableStateOf(false) }
var detailsFor by remember { mutableStateOf<FileEntry?>(null) } var detailsFor by remember { mutableStateOf<FileEntry?>(null) }
var showNewFolder by remember { mutableStateOf(false) } var showNewFolder by remember { mutableStateOf(false) }
@@ -101,6 +116,8 @@ fun BrowserScreen(
/** Several files at once: the selection, held while the pattern dialog is open. */ /** Several files at once: the selection, held while the pattern dialog is open. */
var renameAllTargets by remember { mutableStateOf<List<FileEntry>?>(null) } var renameAllTargets by remember { mutableStateOf<List<FileEntry>?>(null) }
var confirmDelete by remember { mutableStateOf<List<StoragePath>?>(null) } var confirmDelete by remember { mutableStateOf<List<StoragePath>?>(null) }
// Emptying is the one action here with no undo, so it gets the same question a delete does.
var confirmEmptyTrash by remember { mutableStateOf(false) }
var searchOpen by remember { mutableStateOf(false) } var searchOpen by remember { mutableStateOf(false) }
/* /*
@@ -308,10 +325,24 @@ fun BrowserScreen(
TrashScreen( TrashScreen(
batches = overlay.batches, batches = overlay.batches,
loading = overlay.loading, loading = overlay.loading,
retentionHours = Trash.RETENTION_HOURS,
onRestore = { batch -> viewModel.restoreFromTrash(batch) }, onRestore = { batch -> viewModel.restoreFromTrash(batch) },
onRestoreEntry = { batch, entry -> viewModel.restoreFromTrash(batch, entry) }, onRestoreEntry = { batch, entry -> viewModel.restoreFromTrash(batch, entry) },
onEmpty = { confirmEmptyTrash = true },
onClose = viewModel::closeOverlay, onClose = viewModel::closeOverlay,
) )
if (confirmEmptyTrash) {
ConfirmDialog(
title = stringResource(R.string.trash_empty_confirm_title),
body = stringResource(R.string.trash_empty_confirm_body),
confirmLabel = stringResource(R.string.trash_empty_confirm_action),
onDismiss = { confirmEmptyTrash = false },
onConfirm = {
confirmEmptyTrash = false
viewModel.emptyTrash()
},
)
}
return return
} }
@@ -431,7 +462,7 @@ fun BrowserScreen(
renameTarget = selected.singleOrNull() renameTarget = selected.singleOrNull()
} }
}, },
onCompress = { viewModel.compress(state.selection.toList()) }, onCompress = { viewModel.beginCompress(state.selection.toList()) },
onExtract = { onExtract = {
state.selectedEntries.singleOrNull()?.let(viewModel::extract) state.selectedEntries.singleOrNull()?.let(viewModel::extract)
}, },
@@ -583,6 +614,14 @@ fun BrowserScreen(
) )
} }
state.compressRequest?.let { request ->
CompressDialog(
initialName = request.name,
onConfirm = viewModel::confirmCompress,
onDismiss = viewModel::cancelCompress,
)
}
state.transferPicker?.let { picker -> state.transferPicker?.let { picker ->
TransferToDialog( TransferToDialog(
picker = picker, picker = picker,
@@ -37,6 +37,9 @@ data class Clipboard(val paths: List<StoragePath>, val cut: Boolean) {
val isEmpty: Boolean get() = paths.isEmpty() val isEmpty: Boolean get() = paths.isEmpty()
} }
/** A compress waiting for its two answers: the archive's name, and whether to compress it. */
data class CompressRequest(val paths: List<StoragePath>, val name: String)
/** /**
* A folder the "copy to…" picker is offering: a volume root, or a folder inside one. * A folder the "copy to…" picker is offering: a volume root, or a folder inside one.
* *
@@ -323,8 +326,17 @@ data class BrowserUiState(
/** The entry the wide-screen detail pane is showing; `null` until the user picks one. */ /** The entry the wide-screen detail pane is showing; `null` until the user picks one. */
val focus: DetailFocus? = null, val focus: DetailFocus? = null,
val clipboard: Clipboard? = null, val clipboard: Clipboard? = null,
/**
* The rows an operation produced in this folder since it was last read, drawn as a dot.
*
* Spent by the read that shows them: the mark means "new since you last looked here", so it is
* cleared by opening the folder again and forgotten when the process ends.
*/
val changed: Set<StoragePath> = emptySet(),
/** The **Copy to… / Move to…** destination picker, when one is open. */ /** The **Copy to… / Move to…** destination picker, when one is open. */
val transferPicker: TransferPicker? = null, val transferPicker: TransferPicker? = null,
/** A compress waiting for its name and its compression choice. */
val compressRequest: CompressRequest? = null,
val loading: Boolean = false, val loading: Boolean = false,
val freeBytes: Long? = null, val freeBytes: Long? = null,
val message: UiMessage? = null, val message: UiMessage? = null,
@@ -21,6 +21,7 @@ import de.softwareapp_hb.filemanager.core.FileKind
import de.softwareapp_hb.filemanager.core.NameAllocator import de.softwareapp_hb.filemanager.core.NameAllocator
import de.softwareapp_hb.filemanager.core.SortDirection import de.softwareapp_hb.filemanager.core.SortDirection
import de.softwareapp_hb.filemanager.core.SortKey import de.softwareapp_hb.filemanager.core.SortKey
import de.softwareapp_hb.filemanager.core.ShortcutRoute
import de.softwareapp_hb.filemanager.core.SortSpec import de.softwareapp_hb.filemanager.core.SortSpec
import de.softwareapp_hb.filemanager.core.SortedMerge import de.softwareapp_hb.filemanager.core.SortedMerge
import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.core.StoragePath
@@ -135,6 +136,15 @@ class BrowserViewModel(
*/ */
private var selectionToRestore: Set<StoragePath> = emptySet() private var selectionToRestore: Set<StoragePath> = emptySet()
/**
* What each folder gained since it was last read, waiting to be shown as a dot.
*
* In memory and per folder, for the same reason the clipboard is: the mark means "new since you last
* looked here", so it is spent by the read that shows it, and a restart starts clean. Nothing here
* needs a timestamp, a timer or a background job — a row is new because an operation said so.
*/
private val changedFolders = HashMap<StoragePath, MutableSet<StoragePath>>()
/** The running recursive search, so the next keystroke can supersede it. */ /** The running recursive search, so the next keystroke can supersede it. */
private var searchJob: Job? = null private var searchJob: Job? = null
@@ -312,10 +322,14 @@ class BrowserViewModel(
// The folder is announced before anything is read, so leaving the previous one is immediate // The folder is announced before anything is read, so leaving the previous one is immediate
// and the progress bar has something to sit above. // and the progress bar has something to sit above.
listing = emptyList() listing = emptyList()
// Taken (not read) here: the marks are spent by the read that shows them, so a second visit
// to the folder is a visit to a folder that no longer has anything new in it.
val appeared = changedFolders.remove(path).orEmpty()
_state.update { _state.update {
it.copy( it.copy(
path = path, path = path,
category = null, category = null,
changed = appeared,
breadcrumbs = breadcrumbsOf(path), breadcrumbs = breadcrumbsOf(path),
selection = emptySet(), selection = emptySet(),
focus = null, focus = null,
@@ -1343,6 +1357,20 @@ class BrowserViewModel(
} }
} }
/**
* Open what a launcher shortcut asked for.
*
* Both libraries and the trash already exist as first-class views, so this is a mapping and nothing else —
* the shortcut adds a way in, not a mode.
*/
fun openShortcut(route: ShortcutRoute) {
when (route) {
ShortcutRoute.IMAGES -> openCategory(FileCategory.IMAGES)
ShortcutRoute.RECENT -> openCategory(FileCategory.RECENT)
ShortcutRoute.TRASH -> openTrash()
}
}
fun openUsage() { fun openUsage() {
val path = _state.value.path ?: return val path = _state.value.path ?: return
val source = storage.activeSource() ?: return val source = storage.activeSource() ?: return
@@ -1382,6 +1410,12 @@ class BrowserViewModel(
_state.update { it.copy(overlay = Overlay.Trash(emptyList(), loading = true)) } _state.update { it.copy(overlay = Overlay.Trash(emptyList(), loading = true)) }
viewModelScope.launch { viewModelScope.launch {
// The sweep runs here, not only after the next delete: a retention that only fires when the user
// deletes something else leaves day-old batches on the volume forever. Best effort — a volume
// that has gone away is not an error on this screen.
runCatching {
for (volume in volumes()) Trash(source, volume, trashIndex, clock = clock).purgeExpired()
}
val batches = runCatching { Trash.restorableFrom(source, trashIndex) } val batches = runCatching { Trash.restorableFrom(source, trashIndex) }
.getOrDefault(emptyList()) .getOrDefault(emptyList())
_state.update { current -> _state.update { current ->
@@ -1397,6 +1431,27 @@ class BrowserViewModel(
* A name taken again in the meantime is not overwritten — see [Trash.restore] — so the * A name taken again in the meantime is not overwritten — see [Trash.restore] — so the
* item comes back beside whatever holds the name now. * item comes back beside whatever holds the name now.
*/ */
/**
* Delete everything in the trash for good, on every volume, then show the empty result.
*
* There is no undo here by design — emptying is the decision to make the space come back — so the screen
* asks once before calling this, and this asks nothing.
*/
fun emptyTrash() {
val source = storage.activeSource() ?: return
viewModelScope.launch {
_state.update { it.copy(loading = true) }
val removed = runCatching {
volumes().sumOf { volume -> Trash(source, volume, trashIndex, clock = clock).emptyAll() }
}.getOrDefault(0)
_state.update { it.copy(loading = false) }
message(if (removed > 0) R.string.message_trash_emptied else R.string.trash_empty)
openTrash()
}
}
fun restoreFromTrash(batch: TrashBatch) = restoreBatch(batch) { trash -> fun restoreFromTrash(batch: TrashBatch) = restoreBatch(batch) { trash ->
trash.restore(batch) { notifier.update(it) } trash.restore(batch) { notifier.update(it) }
} }
@@ -1539,26 +1594,69 @@ class BrowserViewModel(
// ---------------------------------------------------------------- archives // ---------------------------------------------------------------- archives
/** Compress [paths] into a new ZIP beside them, under a name that is still free. */ /** Compress [paths] into a new ZIP beside them, under a name that is still free. */
/** Compress [paths] straight away, with the name the folder suggests and compression on. */
fun compress(paths: List<StoragePath>) { fun compress(paths: List<StoragePath>) {
val target = _state.value.path ?: return val target = _state.value.path ?: return
val source = storage.activeSource() ?: return
if (paths.isEmpty()) return if (paths.isEmpty()) return
compressInto(paths, target, suggestedArchiveName(paths), compressContents = true)
}
/**
* Ask for the archive's name and whether to compress it, before writing anything.
*
* Two answers because one of them is a trade the user is better placed to make: compressing saves space
* and costs time, and a file that is about to be copied to a computer is often better **stored** than
* squeezed. The dialog says which is which; this only remembers what was selected.
*/
fun beginCompress(paths: List<StoragePath>) {
if (_state.value.path == null || paths.isEmpty()) return
_state.update { it.copy(compressRequest = CompressRequest(paths, suggestedArchiveName(paths))) }
}
fun cancelCompress() {
_state.update { it.copy(compressRequest = null) }
}
/** Take the dialog's two answers and start the work. */
fun confirmCompress(name: String, compressContents: Boolean) {
val request = _state.value.compressRequest ?: return
val target = _state.value.path ?: return
_state.update { it.copy(compressRequest = null) }
val cleanName = name.trim()
// A name is one path segment, the same rule rename follows.
if (cleanName.isEmpty() || !isUsableName(cleanName)) return
compressInto(request.paths, target, cleanName, compressContents)
}
/** `notes.txt` → `notes.txt.zip`; several files → `archive.zip`, made unique against the folder. */
private fun suggestedArchiveName(paths: List<StoragePath>): String {
val base = if (paths.size == 1) "${paths.first().name}.zip" else "archive.zip" val base = if (paths.size == 1) "${paths.first().name}.zip" else "archive.zip"
val taken = _state.value.entries.mapTo(HashSet()) { it.name } val taken = _state.value.entries.mapTo(HashSet()) { it.name }
val name = NameAllocator.uniqueName(base) { it in taken } return NameAllocator.uniqueName(base) { it in taken }
}
private fun compressInto(
paths: List<StoragePath>,
target: StoragePath,
name: String,
compressContents: Boolean,
) {
val source = storage.activeSource() ?: return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(loading = true, selection = emptySet()) } _state.update { it.copy(loading = true, selection = emptySet()) }
notifier.start(OperationKind.COMPRESS) notifier.start(OperationKind.COMPRESS)
val result = try { val result = try {
ArchiveEngine(source).createZip(paths, target.child(name)) { notifier.update(it) } ArchiveEngine(source)
.createZip(paths, target.child(name), compressContents) { notifier.update(it) }
} finally { } finally {
notifier.stop() notifier.stop()
} }
_state.update { it.copy(loading = false) } _state.update { it.copy(loading = false) }
if (result.succeeded) { if (result.succeeded) {
markChanged(target, listOf(target.child(name)))
messagePlural(R.plurals.message_compressed, result.completed) messagePlural(R.plurals.message_compressed, result.completed)
} else { } else {
report(result) report(result)
@@ -1593,6 +1691,7 @@ class BrowserViewModel(
_state.update { it.copy(loading = false) } _state.update { it.copy(loading = false) }
if (result.succeeded) { if (result.succeeded) {
markChanged(target, listOf(target.child(folderName)))
messagePlural(R.plurals.message_extracted, result.completed) messagePlural(R.plurals.message_extracted, result.completed)
} else { } else {
report(result) report(result)
@@ -1824,9 +1923,9 @@ class BrowserViewModel(
_state.update { it.copy(loading = true) } _state.update { it.copy(loading = true) }
notifier.start(kind) notifier.start(kind)
// A move is destructive, so how to reverse it is worked out by diffing the target's // Names either side of the operation. What the target gained is what the dot marks; for a move the
// listing either side of the operation — see [matchLanded]. // same difference is the undo handle — see [matchLanded].
val landedBefore = if (cut) targetNames(source, target) else emptySet() val landedBefore = targetNames(source, target)
val result: OperationResult = try { val result: OperationResult = try {
if (cut) { if (cut) {
@@ -1842,8 +1941,10 @@ class BrowserViewModel(
_state.update { it.copy(loading = false, clipboard = if (cut) null else it.clipboard) } _state.update { it.copy(loading = false, clipboard = if (cut) null else it.clipboard) }
if (result.succeeded) { if (result.succeeded) {
val landedNow = targetNames(source, target)
markChanged(target, (landedNow - landedBefore).map { target.child(it) })
if (cut) { if (cut) {
matchLanded(target, landedBefore, targetNames(source, target), paths)?.let { moves -> matchLanded(target, landedBefore, landedNow, paths)?.let { moves ->
_state.update { it.copy(undo = Undo.Relocate(moves)) } _state.update { it.copy(undo = Undo.Relocate(moves)) }
} }
messageUndoablePlural(R.plurals.message_moved, result.completed) messageUndoablePlural(R.plurals.message_moved, result.completed)
@@ -2017,6 +2118,7 @@ class BrowserViewModel(
viewModelScope.launch { viewModelScope.launch {
val created = runCatching { source.createDirectory(target.child(cleanName)) }.getOrDefault(false) val created = runCatching { source.createDirectory(target.child(cleanName)) }.getOrDefault(false)
if (created) markChanged(target, listOf(target.child(cleanName)))
message(if (created) R.string.message_folder_created else R.string.message_name_in_use) message(if (created) R.string.message_folder_created else R.string.message_name_in_use)
load(target) load(target)
} }
@@ -2034,6 +2136,7 @@ class BrowserViewModel(
val renamed = runCatching { FileOperations(source).rename(path, parent.child(cleanName)) } val renamed = runCatching { FileOperations(source).rename(path, parent.child(cleanName)) }
.getOrDefault(false) .getOrDefault(false)
if (renamed) { if (renamed) {
markChanged(parent, listOf(parent.child(cleanName)))
// A rename is a move within a single folder, so it needs no separate shape. // A rename is a move within a single folder, so it needs no separate shape.
_state.update { _state.update {
it.copy( it.copy(
@@ -2107,6 +2210,7 @@ class BrowserViewModel(
} }
if (done.isNotEmpty()) { if (done.isNotEmpty()) {
markChanged(parent, done.map { it.from })
_state.update { it.copy(undo = Undo.Relocate(done)) } _state.update { it.copy(undo = Undo.Relocate(done)) }
} }
when { when {
@@ -2158,7 +2262,7 @@ class BrowserViewModel(
*/ */
private suspend fun deleteDirectly(paths: List<StoragePath>) { private suspend fun deleteDirectly(paths: List<StoragePath>) {
val source = storage.activeSource() ?: return val source = storage.activeSource() ?: return
val trash = Trash(source, volumeRootFor(paths), trashIndex) val trash = Trash(source, volumeRootFor(paths), trashIndex, clock = clock)
_state.update { it.copy(loading = true, selection = emptySet()) } _state.update { it.copy(loading = true, selection = emptySet()) }
notifier.start(OperationKind.DELETE) notifier.start(OperationKind.DELETE)
@@ -2286,6 +2390,12 @@ class BrowserViewModel(
) )
} }
/** Remember that [paths] appeared in [folder], for the next read of that folder to mark. */
private fun markChanged(folder: StoragePath, paths: Collection<StoragePath>) {
if (paths.isEmpty()) return
changedFolders.getOrPut(folder) { HashSet() }.addAll(paths)
}
/** A copy into a folder the user picked: names it, and offers to go there. */ /** A copy into a folder the user picked: names it, and offers to go there. */
private fun messageCopiedTo(destination: PickerFolder, count: Int) { private fun messageCopiedTo(destination: PickerFolder, count: Int) {
postMessage( postMessage(
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -27,6 +28,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -56,8 +58,12 @@ import de.softwareapp_hb.filemanager.ui.browser.itemCountLabel
fun TrashScreen( fun TrashScreen(
batches: List<TrashBatch>, batches: List<TrashBatch>,
loading: Boolean, loading: Boolean,
/** How long an item stays recoverable; the screen says it rather than leaving it to be discovered. */
retentionHours: Long,
onRestore: (TrashBatch) -> Unit, onRestore: (TrashBatch) -> Unit,
onRestoreEntry: (TrashBatch, TrashedEntry) -> Unit, onRestoreEntry: (TrashBatch, TrashedEntry) -> Unit,
/** Delete every batch for good, after the caller has asked. */
onEmpty: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
Scaffold( Scaffold(
@@ -73,14 +79,36 @@ fun TrashScreen(
) )
} }
}, },
actions = {
// Offered only when there is something to empty: a disabled bin on an empty screen is
// a question with no answer.
if (batches.isNotEmpty()) {
IconButton(onClick = onEmpty) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.trash_empty_action),
)
}
}
},
) )
}, },
) { innerPadding -> ) { innerPadding ->
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding),
) { ) {
Text(
text = pluralStringResource(
R.plurals.trash_keep_for,
retentionHours.toInt(),
retentionHours.toInt(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
if (batches.isEmpty() && !loading) { if (batches.isEmpty() && !loading) {
EmptyState( EmptyState(
message = stringResource(R.string.trash_empty), message = stringResource(R.string.trash_empty),
@@ -104,7 +132,7 @@ fun TrashScreen(
if (loading) { if (loading) {
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter), modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally),
) )
} }
} }
+25
View File
@@ -108,6 +108,13 @@
<string name="browser_action_copy">Copy</string> <string name="browser_action_copy">Copy</string>
<string name="browser_action_cut">Move</string> <string name="browser_action_cut">Move</string>
<string name="browser_action_paste">Paste here</string> <string name="browser_action_paste">Paste here</string>
<!-- Compressing: the name, and whether to squeeze the contents. -->
<string name="compress_dialog_title">Compress</string>
<string name="compress_dialog_name">Archive name</string>
<string name="compress_dialog_contents">Compress contents</string>
<string name="compress_dialog_hint">Off stores the files as they are: faster to build, and ready to copy off the phone.</string>
<string name="compress_dialog_confirm">Compress</string>
<string name="compress_dialog_cancel">Cancel</string>
<!-- The two verbs that pick the destination instead of pasting into the folder on screen. --> <!-- The two verbs that pick the destination instead of pasting into the folder on screen. -->
<string name="browser_action_copy_to">Copy to…</string> <string name="browser_action_copy_to">Copy to…</string>
<string name="browser_action_move_to">Move to…</string> <string name="browser_action_move_to">Move to…</string>
@@ -125,6 +132,15 @@
<string name="browser_action_select_all">Select all</string> <string name="browser_action_select_all">Select all</string>
<string name="browser_action_clear_selection">Clear selection</string> <string name="browser_action_clear_selection">Clear selection</string>
<string name="browser_action_undo">Undo</string> <string name="browser_action_undo">Undo</string>
<!-- Launcher shortcuts: the short label is under the icon, the long one is the tooltip. -->
<string name="shortcut_images">Images</string>
<string name="shortcut_images_long">Open the Images library</string>
<string name="shortcut_recent">Recent</string>
<string name="shortcut_recent_long">Open the files you changed last</string>
<string name="shortcut_trash">Trash</string>
<string name="shortcut_trash_long">Open the trash</string>
<!-- Said by TalkBack for the dot on a row an operation has just produced. -->
<string name="browser_row_changed">New</string>
<!-- The action on a "copied to X" message: go to the folder it was copied into. --> <!-- The action on a "copied to X" message: go to the folder it was copied into. -->
<string name="browser_action_show">Show</string> <string name="browser_action_show">Show</string>
@@ -139,6 +155,15 @@
<!-- ===== Trash ===== --> <!-- ===== Trash ===== -->
<string name="trash_title">Trash</string> <string name="trash_title">Trash</string>
<plurals name="trash_keep_for">
<item quantity="one">Deleted files are kept for %1$d hour</item>
<item quantity="other">Deleted files are kept for %1$d hours</item>
</plurals>
<string name="trash_empty_action">Empty trash</string>
<string name="trash_empty_confirm_title">Empty the trash?</string>
<string name="trash_empty_confirm_body">Everything in the trash is deleted for good. This cannot be undone.</string>
<string name="trash_empty_confirm_action">Delete for good</string>
<string name="message_trash_emptied">Trash emptied</string>
<string name="trash_empty">Nothing in the trash</string> <string name="trash_empty">Nothing in the trash</string>
<string name="trash_restore">Restore</string> <string name="trash_restore">Restore</string>
<string name="trash_restore_all">Restore all</string> <string name="trash_restore_all">Restore all</string>
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Long-press the app icon: three views that are otherwise two or three taps deep.
Each shortcut is a plain intent into MainActivity carrying a route in an extra; MainActivity turns it into
ShortcutRoute and the browser opens that view once, when it appears. No permission, no database, and no
dynamic shortcuts to keep in sync with the favourites list.
-->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:enabled="true"
android:shortcutId="images"
android:shortcutLongLabel="@string/shortcut_images_long"
android:shortcutShortLabel="@string/shortcut_images">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="de.softwareapp_hb.filemanager.MainActivity"
android:targetPackage="de.softwareapp_hb.filemanager">
<extra
android:name="de.softwareapp_hb.filemanager.SHORTCUT"
android:value="images" />
</intent>
</shortcut>
<shortcut
android:enabled="true"
android:shortcutId="recent"
android:shortcutLongLabel="@string/shortcut_recent_long"
android:shortcutShortLabel="@string/shortcut_recent">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="de.softwareapp_hb.filemanager.MainActivity"
android:targetPackage="de.softwareapp_hb.filemanager">
<extra
android:name="de.softwareapp_hb.filemanager.SHORTCUT"
android:value="recent" />
</intent>
</shortcut>
<shortcut
android:enabled="true"
android:shortcutId="trash"
android:shortcutLongLabel="@string/shortcut_trash_long"
android:shortcutShortLabel="@string/shortcut_trash">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="de.softwareapp_hb.filemanager.MainActivity"
android:targetPackage="de.softwareapp_hb.filemanager">
<extra
android:name="de.softwareapp_hb.filemanager.SHORTCUT"
android:value="trash" />
</intent>
</shortcut>
</shortcuts>
@@ -0,0 +1,30 @@
package de.softwareapp_hb.filemanager.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The mapping between a launcher shortcut and a view.
*
* The interesting case is the one that is *not* a shortcut: a value written by an older build, or a typo in
* `shortcuts.xml`, must be ignored rather than thrown, because the app is being opened by the launcher at that
* moment and has nowhere to show an error.
*/
class ShortcutRouteTest {
@Test
fun `each shortcut key maps to its route`() {
assertEquals(ShortcutRoute.IMAGES, ShortcutRoute.from("images"))
assertEquals(ShortcutRoute.RECENT, ShortcutRoute.from("recent"))
assertEquals(ShortcutRoute.TRASH, ShortcutRoute.from("trash"))
}
@Test
fun `anything else is ignored rather than thrown`() {
assertNull(ShortcutRoute.from(null))
assertNull(ShortcutRoute.from(""))
assertNull(ShortcutRoute.from("favourites"))
assertNull(ShortcutRoute.from("IMAGES"))
}
}
@@ -52,6 +52,29 @@ class ArchiveEngineTest {
assertEquals(2, extracted.completed) assertEquals(2, extracted.completed)
} }
@Test
fun `an uncompressed zip round-trips and is larger than a squeezed one`() = runBlocking {
val storage = storage()
// Very compressible on purpose: what the two archives differ by is then the compression itself,
// not the few bytes of header each entry carries.
storage.addFile(src.child("big.txt"), "a".repeat(4000))
val engine = ArchiveEngine(storage)
val stored = StoragePath.of("/src/stored.zip")
val squeezed = StoragePath.of("/src/squeezed.zip")
assertTrue(engine.createZip(listOf(src.child("big.txt")), stored, compress = false).succeeded)
assertTrue(engine.createZip(listOf(src.child("big.txt")), squeezed, compress = true).succeeded)
val storedBytes = storage.stat(stored)!!.sizeBytes
val squeezedBytes = storage.stat(squeezed)!!.sizeBytes
assertTrue("stored=$storedBytes", storedBytes > 4000)
assertTrue("squeezed=$squeezedBytes", squeezedBytes < 500)
// Storing is a choice about cost, not about format: it is still a zip, and still the same bytes.
assertTrue(engine.extract(stored, out).succeeded)
assertEquals("a".repeat(4000), storage.contentOf(out.child("big.txt")))
}
@Test @Test
fun `createZip reports progress over the files it will write`() = runBlocking { fun `createZip reports progress over the files it will write`() = runBlocking {
val storage = storage() val storage = storage()
@@ -36,6 +36,22 @@ class TrashTest {
index: TrashIndex = TrashIndex.None, index: TrashIndex = TrashIndex.None,
) = Trash(source, volume, index, clock = { at }) ) = Trash(source, volume, index, clock = { at })
@Test
fun `emptying removes every batch and leaves what is not one`() = runBlocking {
val storage = storage()
trash(storage, at = TRASHED_AT).stage(listOf(notes))
trash(storage, at = TRASHED_AT + 1).stage(listOf(docs.child("report.pdf")))
// Something else in the same hidden folder: the volume is the user's, not the trash's to tidy.
val foreign = trash(storage).root().child("holiday-photos")
storage.createDirectory(foreign)
val removed = trash(storage).emptyAll()
assertEquals(2, removed)
assertTrue(storage.contains(foreign))
assertTrue(trash(storage).restorable().isEmpty())
}
@Test @Test
fun `staging moves an item aside and records where it came from`() = runBlocking { fun `staging moves an item aside and records where it came from`() = runBlocking {
val storage = storage() val storage = storage()
@@ -0,0 +1,68 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The dot that marks a row an operation produced.
*
* Present when the view model says so, absent otherwise, and **readable**: a dot is the only thing on the
* row that says "new", so it carries a content description rather than being a shape TalkBack skips.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class BrowserComponentsTest {
@get:Rule
val compose = createComposeRule()
private val downloads = StoragePath.of("/storage/emulated/0/Download")
private fun entry(name: String) =
FileEntry(downloads.child(name), isDirectory = false, sizeBytes = 4L, lastModifiedMillis = 0L)
@Test
fun `a row an operation produced carries the mark`() {
compose.setContent {
FileManagerTheme {
EntryRow(entry = entry("a.txt"), selected = false, changed = true, onClick = {})
}
}
compose.onNodeWithContentDescription("New").assertExists()
}
@Test
fun `an ordinary row carries no mark`() {
compose.setContent {
FileManagerTheme {
EntryRow(entry = entry("a.txt"), selected = false, onClick = {})
}
}
compose.onAllNodesWithContentDescription("New").assertCountEquals(0)
}
@Test
fun `a grid cell an operation produced carries the mark too`() {
compose.setContent {
FileManagerTheme {
EntryGridCell(entry = entry("a.txt"), selected = false, changed = true, onClick = {})
}
}
compose.onNodeWithContentDescription("New").assertExists()
}
}
@@ -9,6 +9,7 @@ import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.BatchRename import de.softwareapp_hb.filemanager.core.BatchRename
import de.softwareapp_hb.filemanager.core.ConflictPolicy import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileCategory
import de.softwareapp_hb.filemanager.core.ShortcutRoute
import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.core.FileKind
import de.softwareapp_hb.filemanager.core.SortDirection import de.softwareapp_hb.filemanager.core.SortDirection
@@ -30,6 +31,8 @@ import de.softwareapp_hb.filemanager.domain.OperationKind
import de.softwareapp_hb.filemanager.domain.OperationNotifier import de.softwareapp_hb.filemanager.domain.OperationNotifier
import de.softwareapp_hb.filemanager.domain.OperationProgress import de.softwareapp_hb.filemanager.domain.OperationProgress
import de.softwareapp_hb.filemanager.domain.SearchIndexer import de.softwareapp_hb.filemanager.domain.SearchIndexer
import de.softwareapp_hb.filemanager.domain.Trash
import de.softwareapp_hb.filemanager.data.TrashIndex
import de.softwareapp_hb.filemanager.domain.testutil.InMemorySearchIndex import de.softwareapp_hb.filemanager.domain.testutil.InMemorySearchIndex
import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
import de.softwareapp_hb.filemanager.ui.browser.testutil.FakeStorageLocation import de.softwareapp_hb.filemanager.ui.browser.testutil.FakeStorageLocation
@@ -439,6 +442,150 @@ class BrowserViewModelTest {
assertEquals(R.string.browser_action_undo, message.actionLabelRes) assertEquals(R.string.browser_action_undo, message.actionLabelRes)
} }
@Test
fun `a copy into the folder on screen marks the copy`() {
val model = viewModel()
model.openDirectory(documents)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
model.paste()
assertEquals(setOf(documents.child("a.txt")), model.state.value.changed)
}
@Test
fun `a copy into another folder marks it there, not here`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
model.pickerOpen(documents)
model.confirmTransferTo()
// The user is still in `downloads`, which gained nothing — the copy went elsewhere.
assertTrue(model.state.value.changed.isEmpty())
model.openDirectory(documents)
assertEquals(setOf(documents.child("a.txt")), model.state.value.changed)
}
@Test
fun `reading the folder again spends its marks`() {
val model = viewModel()
model.openDirectory(documents)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
model.paste()
assertTrue(model.state.value.changed.isNotEmpty())
model.openDirectory(root)
model.openDirectory(documents)
assertTrue(model.state.value.changed.isEmpty())
}
@Test
fun `a rename marks the name it produced`() {
val model = viewModel()
model.openDirectory(downloads)
model.rename(downloads.child("a.txt"), "renamed.txt")
assertEquals(setOf(downloads.child("renamed.txt")), model.state.value.changed)
}
@Test
fun `compressing asks for a name and a choice before writing`() {
val model = viewModel()
model.openDirectory(downloads)
model.beginCompress(listOf(downloads.child("a.txt")))
val request = model.state.value.compressRequest
assertNotNull(request)
assertEquals("a.txt.zip", request!!.name)
// Nothing has been written: both answers belong to the dialog.
assertFalse(storage.contains(downloads.child("a.txt.zip")))
}
@Test
fun `a confirmed compress writes the name that was typed, and marks it`() {
val model = viewModel()
model.openDirectory(downloads)
model.beginCompress(listOf(downloads.child("a.txt")))
model.confirmCompress("fast.zip", compressContents = false)
assertTrue(storage.contains(downloads.child("fast.zip")))
assertNull(model.state.value.compressRequest)
assertEquals(setOf(downloads.child("fast.zip")), model.state.value.changed)
}
@Test
fun `cancelling the compress dialog writes nothing`() {
val model = viewModel()
model.openDirectory(downloads)
model.beginCompress(listOf(downloads.child("a.txt")))
model.cancelCompress()
assertNull(model.state.value.compressRequest)
assertFalse(storage.contains(downloads.child("a.txt.zip")))
}
@Test
fun `opening the trash sweeps what has expired`() {
val stagedAt = 1_000L
val trash = Trash(storage, root, TrashIndex.None, clock = { stagedAt })
runBlocking { trash.stage(listOf(downloads.child("a.txt"))) }
assertFalse(storage.contains(downloads.child("a.txt")))
// A day and a moment later: the batch is past the window, and opening the trash is what notices.
val model = viewModel(clock = { stagedAt + Trash.RETENTION_MILLIS + 1 })
model.openTrash()
assertEquals(0, runBlocking { storage.list(trash.root()) }.size)
}
@Test
fun `opening the trash keeps what is still inside the window`() {
val stagedAt = 9_000_000L
val trash = Trash(storage, root, TrashIndex.None, clock = { stagedAt })
runBlocking { trash.stage(listOf(downloads.child("a.txt"))) }
val model = viewModel(clock = { stagedAt })
model.openTrash()
assertEquals(1, runBlocking { storage.list(trash.root()) }.size)
}
@Test
fun `emptying the trash removes what is in it`() {
val stagedAt = 9_000_000L
val trash = Trash(storage, root, TrashIndex.None, clock = { stagedAt })
runBlocking { trash.stage(listOf(downloads.child("a.txt"))) }
val model = viewModel(clock = { stagedAt })
model.emptyTrash()
assertEquals(0, runBlocking { storage.list(trash.root()) }.size)
assertEquals(R.string.message_trash_emptied, model.state.value.message?.textRes)
}
@Test
fun `a shortcut opens the view it names`() {
val model = viewModel()
model.openShortcut(ShortcutRoute.IMAGES)
assertEquals(FileCategory.IMAGES, model.state.value.category)
model.openShortcut(ShortcutRoute.RECENT)
assertEquals(FileCategory.RECENT, model.state.value.category)
model.openShortcut(ShortcutRoute.TRASH)
assertTrue(model.state.value.overlay is Overlay.Trash)
}
@Test @Test
fun `navigating up goes to the parent then to the roots`() { fun `navigating up goes to the parent then to the roots`() {
val model = viewModel() val model = viewModel()
+4 -4
View File
@@ -9,7 +9,7 @@
<ID>CyclomaticComplexMethod:ArchiveFormats.kt$ArchiveFormats$fun formatOf(fileName: String): ArchiveFormat</ID> <ID>CyclomaticComplexMethod:ArchiveFormats.kt$ArchiveFormats$fun formatOf(fileName: String): ArchiveFormat</ID>
<ID>CyclomaticComplexMethod:BrowserContent.kt$@Composable fun BrowserContent( state: BrowserUiState, contentPadding: PaddingValues, crumbLabel: (StoragePath) -&gt; String, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onFocusRoot: (RootItem) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onOpenRoot: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onOpenCategory: (FileCategory) -&gt; Unit, onOpenBookmark: (StoragePath) -&gt; Unit, onRemoveBookmark: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onPickFolder: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, /** Hoisted by the screen so the scroll position outlives a viewer taking over. */ listState: LazyListState = rememberLazyListState(), gridState: LazyGridState = rememberLazyGridState(), )</ID> <ID>CyclomaticComplexMethod:BrowserContent.kt$@Composable fun BrowserContent( state: BrowserUiState, contentPadding: PaddingValues, crumbLabel: (StoragePath) -&gt; String, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onFocusRoot: (RootItem) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onOpenRoot: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onOpenCategory: (FileCategory) -&gt; Unit, onOpenBookmark: (StoragePath) -&gt; Unit, onRemoveBookmark: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onPickFolder: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, /** Hoisted by the screen so the scroll position outlives a viewer taking over. */ listState: LazyListState = rememberLazyListState(), gridState: LazyGridState = rememberLazyGridState(), )</ID>
<ID>CyclomaticComplexMethod:BrowserContent.kt$@Composable private fun DirectoryView( state: BrowserUiState, crumbLabel: (StoragePath) -&gt; String, wide: Boolean, listState: LazyListState, gridState: LazyGridState, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, modifier: Modifier = Modifier, )</ID> <ID>CyclomaticComplexMethod:BrowserContent.kt$@Composable private fun DirectoryView( state: BrowserUiState, crumbLabel: (StoragePath) -&gt; String, wide: Boolean, listState: LazyListState, gridState: LazyGridState, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, modifier: Modifier = Modifier, )</ID>
<ID>CyclomaticComplexMethod:BrowserScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserScreen( incoming: IncomingRequest? = null, onDeliver: (FileEntry) -&gt; Unit = {}, onDone: () -&gt; Unit = {}, viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory), )</ID> <ID>CyclomaticComplexMethod:BrowserScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserScreen( incoming: IncomingRequest? = null, startRoute: ShortcutRoute? = null, onDeliver: (FileEntry) -&gt; Unit = {}, onDone: () -&gt; Unit = {}, viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory), )</ID>
<ID>CyclomaticComplexMethod:BrowserViewModel.kt$BrowserViewModel$fun open(entry: FileEntry)</ID> <ID>CyclomaticComplexMethod:BrowserViewModel.kt$BrowserViewModel$fun open(entry: FileEntry)</ID>
<ID>CyclomaticComplexMethod:BrowserViewModel.kt$BrowserViewModel$fun previewArchiveEntry(entry: ArchiveEntry)</ID> <ID>CyclomaticComplexMethod:BrowserViewModel.kt$BrowserViewModel$fun previewArchiveEntry(entry: ArchiveEntry)</ID>
<ID>CyclomaticComplexMethod:FileOperations.kt$FileOperations$private suspend fun transfer( from: StoragePath, to: StoragePath, move: Boolean, state: OperationState, )</ID> <ID>CyclomaticComplexMethod:FileOperations.kt$FileOperations$private suspend fun transfer( from: StoragePath, to: StoragePath, move: Boolean, state: OperationState, )</ID>
@@ -17,7 +17,7 @@
<ID>LargeClass:BrowserViewModel.kt$BrowserViewModel : ViewModel</ID> <ID>LargeClass:BrowserViewModel.kt$BrowserViewModel : ViewModel</ID>
<ID>LargeClass:BrowserViewModelTest.kt$BrowserViewModelTest</ID> <ID>LargeClass:BrowserViewModelTest.kt$BrowserViewModelTest</ID>
<ID>LongMethod:BrowserContent.kt$@Composable fun BrowserContent( state: BrowserUiState, contentPadding: PaddingValues, crumbLabel: (StoragePath) -&gt; String, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onFocusRoot: (RootItem) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onOpenRoot: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onOpenCategory: (FileCategory) -&gt; Unit, onOpenBookmark: (StoragePath) -&gt; Unit, onRemoveBookmark: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onPickFolder: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, /** Hoisted by the screen so the scroll position outlives a viewer taking over. */ listState: LazyListState = rememberLazyListState(), gridState: LazyGridState = rememberLazyGridState(), )</ID> <ID>LongMethod:BrowserContent.kt$@Composable fun BrowserContent( state: BrowserUiState, contentPadding: PaddingValues, crumbLabel: (StoragePath) -&gt; String, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onFocusRoot: (RootItem) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onOpenRoot: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onOpenCategory: (FileCategory) -&gt; Unit, onOpenBookmark: (StoragePath) -&gt; Unit, onRemoveBookmark: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onPickFolder: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, /** Hoisted by the screen so the scroll position outlives a viewer taking over. */ listState: LazyListState = rememberLazyListState(), gridState: LazyGridState = rememberLazyGridState(), )</ID>
<ID>LongMethod:BrowserScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserScreen( incoming: IncomingRequest? = null, onDeliver: (FileEntry) -&gt; Unit = {}, onDone: () -&gt; Unit = {}, viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory), )</ID> <ID>LongMethod:BrowserScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserScreen( incoming: IncomingRequest? = null, startRoute: ShortcutRoute? = null, onDeliver: (FileEntry) -&gt; Unit = {}, onDone: () -&gt; Unit = {}, viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory), )</ID>
<ID>LongMethod:BrowserTopBar.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserTopBar( title: String, canNavigateUp: Boolean, /** Whether this view is a folder, and so can be starred at all. */ canBookmark: Boolean, bookmarked: Boolean, query: String, searchOpen: Boolean, searchScope: SearchScope, /** Whether the scope is the user's to choose at all; see the note beside the toggle. */ scopeChoosable: Boolean, canPaste: Boolean, onUp: () -&gt; Unit, onQuery: (String) -&gt; Unit, onSearchOpen: (Boolean) -&gt; Unit, onSearchScope: () -&gt; Unit, onSort: () -&gt; Unit, onPaste: () -&gt; Unit, onToggleBookmark: () -&gt; Unit, onRefresh: () -&gt; Unit, onUsage: () -&gt; Unit, onDuplicates: () -&gt; Unit, onTrash: () -&gt; Unit, )</ID> <ID>LongMethod:BrowserTopBar.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserTopBar( title: String, canNavigateUp: Boolean, /** Whether this view is a folder, and so can be starred at all. */ canBookmark: Boolean, bookmarked: Boolean, query: String, searchOpen: Boolean, searchScope: SearchScope, /** Whether the scope is the user's to choose at all; see the note beside the toggle. */ scopeChoosable: Boolean, canPaste: Boolean, onUp: () -&gt; Unit, onQuery: (String) -&gt; Unit, onSearchOpen: (Boolean) -&gt; Unit, onSearchScope: () -&gt; Unit, onSort: () -&gt; Unit, onPaste: () -&gt; Unit, onToggleBookmark: () -&gt; Unit, onRefresh: () -&gt; Unit, onUsage: () -&gt; Unit, onDuplicates: () -&gt; Unit, onTrash: () -&gt; Unit, )</ID>
<ID>LongParameterList:BrowserContent.kt$( state: BrowserUiState, contentPadding: PaddingValues, crumbLabel: (StoragePath) -&gt; String, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onFocusRoot: (RootItem) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onOpenRoot: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onOpenCategory: (FileCategory) -&gt; Unit, onOpenBookmark: (StoragePath) -&gt; Unit, onRemoveBookmark: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onPickFolder: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, /** Hoisted by the screen so the scroll position outlives a viewer taking over. */ listState: LazyListState = rememberLazyListState(), gridState: LazyGridState = rememberLazyGridState(), )</ID> <ID>LongParameterList:BrowserContent.kt$( state: BrowserUiState, contentPadding: PaddingValues, crumbLabel: (StoragePath) -&gt; String, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onFocusRoot: (RootItem) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onOpenRoot: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onOpenCategory: (FileCategory) -&gt; Unit, onOpenBookmark: (StoragePath) -&gt; Unit, onRemoveBookmark: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onPickFolder: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, /** Hoisted by the screen so the scroll position outlives a viewer taking over. */ listState: LazyListState = rememberLazyListState(), gridState: LazyGridState = rememberLazyGridState(), )</ID>
<ID>LongParameterList:BrowserContent.kt$( state: BrowserUiState, crumbLabel: (StoragePath) -&gt; String, wide: Boolean, listState: LazyListState, gridState: LazyGridState, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, modifier: Modifier = Modifier, )</ID> <ID>LongParameterList:BrowserContent.kt$( state: BrowserUiState, crumbLabel: (StoragePath) -&gt; String, wide: Boolean, listState: LazyListState, gridState: LazyGridState, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, modifier: Modifier = Modifier, )</ID>
@@ -60,13 +60,13 @@
<ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$private suspend fun readArchive( archive: ArchiveInputStream&lt;*&gt;, destination: StoragePath, state: ArchiveState, )</ID> <ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$private suspend fun readArchive( archive: ArchiveInputStream&lt;*&gt;, destination: StoragePath, state: ArchiveState, )</ID>
<ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$private suspend fun readSevenZip( channel: SeekableByteChannel, destination: StoragePath, state: ArchiveState, )</ID> <ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$private suspend fun readSevenZip( channel: SeekableByteChannel, destination: StoragePath, state: ArchiveState, )</ID>
<ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$private suspend fun withEntry( archive: StoragePath, name: String, sink: suspend (InputStream) -&gt; Unit, ): Boolean</ID> <ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$private suspend fun withEntry( archive: StoragePath, name: String, sink: suspend (InputStream) -&gt; Unit, ): Boolean</ID>
<ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$suspend fun createZip( entries: List&lt;StoragePath&gt;, destination: StoragePath, onProgress: (OperationProgress) -&gt; Unit = {}, ): OperationResult</ID> <ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$suspend fun createZip( entries: List&lt;StoragePath&gt;, destination: StoragePath, compress: Boolean = true, onProgress: (OperationProgress) -&gt; Unit = {}, ): OperationResult</ID>
<ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$suspend fun extract( archive: StoragePath, destination: StoragePath, onProgress: (OperationProgress) -&gt; Unit = {}, ): OperationResult</ID> <ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$suspend fun extract( archive: StoragePath, destination: StoragePath, onProgress: (OperationProgress) -&gt; Unit = {}, ): OperationResult</ID>
<ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$suspend fun list(archive: StoragePath): List&lt;ArchiveEntry&gt;</ID> <ID>NestedBlockDepth:ArchiveEngine.kt$ArchiveEngine$suspend fun list(archive: StoragePath): List&lt;ArchiveEntry&gt;</ID>
<ID>NestedBlockDepth:ArchiveEngineTest.kt$ArchiveEngineTest$private fun sevenZip(vararg entries: Pair&lt;String, String&gt;): ByteArray</ID> <ID>NestedBlockDepth:ArchiveEngineTest.kt$ArchiveEngineTest$private fun sevenZip(vararg entries: Pair&lt;String, String&gt;): ByteArray</ID>
<ID>RethrowCaughtException:BrowserViewModel.kt$BrowserViewModel$throw cancellation</ID> <ID>RethrowCaughtException:BrowserViewModel.kt$BrowserViewModel$throw cancellation</ID>
<ID>ReturnCount:ArchiveEngine.kt$ArchiveEngine$private suspend fun withEntry( archive: StoragePath, name: String, sink: suspend (InputStream) -&gt; Unit, ): Boolean</ID> <ID>ReturnCount:ArchiveEngine.kt$ArchiveEngine$private suspend fun withEntry( archive: StoragePath, name: String, sink: suspend (InputStream) -&gt; Unit, ): Boolean</ID>
<ID>ReturnCount:BrowserScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserScreen( incoming: IncomingRequest? = null, onDeliver: (FileEntry) -&gt; Unit = {}, onDone: () -&gt; Unit = {}, viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory), )</ID> <ID>ReturnCount:BrowserScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserScreen( incoming: IncomingRequest? = null, startRoute: ShortcutRoute? = null, onDeliver: (FileEntry) -&gt; Unit = {}, onDone: () -&gt; Unit = {}, viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory), )</ID>
<ID>TooGenericExceptionCaught:ArchiveEngine.kt$ArchiveEngine$failure: Exception</ID> <ID>TooGenericExceptionCaught:ArchiveEngine.kt$ArchiveEngine$failure: Exception</ID>
<ID>TooGenericExceptionCaught:BrowserViewModel.kt$BrowserViewModel$failure: Exception</ID> <ID>TooGenericExceptionCaught:BrowserViewModel.kt$BrowserViewModel$failure: Exception</ID>
<ID>TooGenericExceptionCaught:FileOperations.kt$FileOperations$failure: Exception</ID> <ID>TooGenericExceptionCaught:FileOperations.kt$FileOperations$failure: Exception</ID>
+40 -15
View File
@@ -2,7 +2,7 @@
Generated from the source by `tools/make-code-map.py` - do not edit by hand. Regenerate it after a change (`python3 tools/make-code-map.py`); `--check` exits non-zero when this file is stale. Generated from the source by `tools/make-code-map.py` - do not edit by hand. Regenerate it after a change (`python3 tools/make-code-map.py`); `--check` exits non-zero when this file is stale.
79 source files, 13481 lines - 35 test files, 409 tests. The behaviour index at the end lists every test name, and in this project a test name is a sentence about what the app does, so it doubles as the specification. 80 source files, 13841 lines - 37 test files, 427 tests. The behaviour index at the end lists every test name, and in this project a test name is a sentence about what the app does, so it doubles as the specification.
Start at `AGENTS.md`. What is done, and why: `docs/ROADMAP.md` and `CHANGELOG.md`. What was verified by hand rather than by a test: `docs/EDGE_CASE_TESTING.md`. Start at `AGENTS.md`. What is done, and why: `docs/ROADMAP.md` and `CHANGELOG.md`. What was verified by hand rather than by a test: `docs/EDGE_CASE_TESTING.md`.
@@ -23,6 +23,7 @@ Pure Kotlin, **no Android imports**: paths, file names, kinds, sorting, formatti
| `MimeTypes.kt` | 110 | Best-effort MIME lookup for the "open with" intent. MimeTypes | `FileEntryTest` (8) | | `MimeTypes.kt` | 110 | Best-effort MIME lookup for the "open with" intent. MimeTypes | `FileEntryTest` (8) |
| `NameAllocator.kt` | 30 | Picks a name that is not already in use, in the familiar "name (1)" style. NameAllocator | `NameMatcherTest` (10) | | `NameAllocator.kt` | 30 | Picks a name that is not already in use, in the familiar "name (1)" style. NameAllocator | `NameMatcherTest` (10) |
| `NameMatcher.kt` | 63 | The in-folder search matcher. NameMatcher | `NameMatcherTest` (10) | | `NameMatcher.kt` | 63 | The in-folder search matcher. NameMatcher | `NameMatcherTest` (10) |
| `ShortcutRoute.kt` | 27 | Where a launcher shortcut asks the app to open. ShortcutRoute | `ShortcutRouteTest` (2) |
| `SortSpec.kt` | 82 | A complete, serialisable description of the ordering of a directory listing. SortKey, SortDirection, SortSpec, NaturalOrder | `SortSpecTest` (8) | | `SortSpec.kt` | 82 | A complete, serialisable description of the ordering of a directory listing. SortKey, SortDirection, SortSpec, NaturalOrder | `SortSpecTest` (8) |
| `SortedMerge.kt` | 42 | Adds a freshly read batch to an already sorted list, in one pass. SortedMerge | `SortedMergeTest` (4) | | `SortedMerge.kt` | 42 | Adds a freshly read batch to an already sorted list, in one pass. SortedMerge | `SortedMergeTest` (4) |
| `StoragePath.kt` | 89 | A normalized, absolute, backend-agnostic storage path. StoragePath | `StoragePathTest` (8) | | `StoragePath.kt` | 89 | A normalized, absolute, backend-agnostic storage path. StoragePath | `StoragePathTest` (8) |
@@ -61,14 +62,14 @@ Operations over a source: copy/move/delete/rename, archives, search walking, dup
| file | lines | what it holds | tests | | file | lines | what it holds | tests |
|---|---|---|---| |---|---|---|---|
| `ArchiveEngine.kt` | 658 | Creates and extracts archives, on top of the storage backends. ArchiveEntry, ArchiveEngine | `ArchiveEngineTest` (30) | | `ArchiveEngine.kt` | 667 | Creates and extracts archives, on top of the storage backends. ArchiveEntry, ArchiveEngine | `ArchiveEngineTest` (31) |
| `DuplicateFinder.kt` | 202 | Finds files that hold the same bytes. DuplicateGroup, DuplicateProgress, DuplicateScan, DuplicateFinder | `DuplicateFinderTest` (8) | | `DuplicateFinder.kt` | 202 | Finds files that hold the same bytes. DuplicateGroup, DuplicateProgress, DuplicateScan, DuplicateFinder | `DuplicateFinderTest` (8) |
| `FileOperations.kt` | 272 | Copy, move and delete, assembled from the StorageSource primitives. FileOperations | `FileOperationsTest` (19) | | `FileOperations.kt` | 272 | Copy, move and delete, assembled from the StorageSource primitives. FileOperations | `FileOperationsTest` (19) |
| `FileSearch.kt` | 79 | Recursive name search. FileSearch | `FileSearchTest` (8) | | `FileSearch.kt` | 79 | Recursive name search. FileSearch | `FileSearchTest` (8) |
| `OperationModels.kt` | 42 | The kind of long-running operation a progress notification can describe. OperationKind, OperationProgress, OperationFailure, OperationResult | - | | `OperationModels.kt` | 42 | The kind of long-running operation a progress notification can describe. OperationKind, OperationProgress, OperationFailure, OperationResult | - |
| `OperationNotifier.kt` | 25 | Keeps the foreground service in step with a running operation. OperationNotifier | - | | `OperationNotifier.kt` | 25 | Keeps the foreground service in step with a running operation. OperationNotifier | - |
| `SearchIndexer.kt` | 173 | Fills a SearchIndex by walking a volume. SearchIndexer | `SearchIndexerTest` (14) | | `SearchIndexer.kt` | 173 | Fills a SearchIndex by walking a volume. SearchIndexer | `SearchIndexerTest` (14) |
| `Trash.kt` | 338 | A staging folder that makes a delete undoable. TrashedEntry, TrashBatch, Trash | `TrashTest` (19) | | `Trash.kt` | 360 | A staging folder that makes a delete undoable. TrashedEntry, TrashBatch, Trash | `TrashTest` (20) |
| `UsageAnalysis.kt` | 95 | Works out where the space went. UsageNode, UsageAnalysis | `UsageAnalysisTest` (6) | | `UsageAnalysis.kt` | 95 | Works out where the space went. UsageNode, UsageAnalysis | `UsageAnalysisTest` (6) |
## service/ ## service/
@@ -95,15 +96,15 @@ The browser screen: view model, state, list and grid, dialogs, selection bar
| file | lines | what it holds | tests | | file | lines | what it holds | tests |
|---|---|---|---| |---|---|---|---|
| `BrowserComponents.kt` | 317 | The thumbnail an entry's leading visual gets in a list row. ListThumbnailSize, GridThumbnailSize, GridCellMinSize, EntryIcon(), FileEntry(), FileEntry(), VIDEO_FRAME_MILLIS, iconFor(), iconFor(), EntryRow(), EntryGridCell(), FileEntry() | - | | `BrowserComponents.kt` | 365 | The thumbnail an entry's leading visual gets in a list row. ListThumbnailSize, GridThumbnailSize, GridCellMinSize, EntryIcon(), FileEntry(), FileEntry(), VIDEO_FRAME_MILLIS, iconFor(), iconFor(), MarkOfChange(), EntryRow(), EntryGridCell(), FileEntry() | `BrowserComponentsTest` (3) |
| `BrowserContent.kt` | 463 | Everything below the app bar: the permission prompt, the roots, or a directory. WIDE_LAYOUT_MIN_WIDTH, MasterPaneWidth, BrowserContent(), LazyListScope(), LIBRARY_SECTION_KEY, LazyListScope(), BOOKMARK_SECTION_KEY, DirectoryView(), FileEntry(), IndexStatusRow(), FileEntry() | - | | `BrowserContent.kt` | 465 | Everything below the app bar: the permission prompt, the roots, or a directory. WIDE_LAYOUT_MIN_WIDTH, MasterPaneWidth, BrowserContent(), LazyListScope(), LIBRARY_SECTION_KEY, LazyListScope(), BOOKMARK_SECTION_KEY, DirectoryView(), FileEntry(), IndexStatusRow(), FileEntry() | - |
| `BrowserDetailPane.kt` | 131 | The second pane of the wide layout: what is inside the folder the user picked, or a preview and the details of the file they picked. DetailPane(), FolderPreview(), ImagePreview() | - | | `BrowserDetailPane.kt` | 131 | The second pane of the wide layout: what is inside the folder the user picked, or a preview and the details of the file they picked. DetailPane(), FolderPreview(), ImagePreview() | - |
| `BrowserDialogs.kt` | 385 | The Copy to… / Move to… picker. TransferToDialog(), titleFor(), hereFor(), BatchRenameDialog(), PREVIEW_NAMES, batchRenameNote(), NameDialog(), ConfirmDialog(), ConflictDialog() | `BrowserDialogsTest` (5) | | `BrowserDialogs.kt` | 444 | Compressing something, with the one choice that changes what comes out. CompressDialog(), TransferToDialog(), titleFor(), hereFor(), BatchRenameDialog(), PREVIEW_NAMES, batchRenameNote(), NameDialog(), ConfirmDialog(), ConflictDialog() | `BrowserDialogsTest` (5) |
| `BrowserRoots.kt` | 270 | A row on the root screen: a volume, with its free space when the platform reports it. RootRow(), itemCountLabel(), EmptyState(), LibrarySectionHeader(), BookmarkSectionHeader(), SectionHeader(), BookmarkRow(), LibraryRow(), Breadcrumbs() | `BrowserRootsTest` (4) | | `BrowserRoots.kt` | 270 | A row on the root screen: a volume, with its free space when the platform reports it. RootRow(), itemCountLabel(), EmptyState(), LibrarySectionHeader(), BookmarkSectionHeader(), SectionHeader(), BookmarkRow(), LibraryRow(), Breadcrumbs() | `BrowserRootsTest` (4) |
| `BrowserScreen.kt` | 622 | The text of a snackbar message. BrowserScreen(), messageText() | - | | `BrowserScreen.kt` | 661 | The browser, and everything that can be on top of it. BrowserScreen(), messageText() | - |
| `BrowserTopBar.kt` | 238 | The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | `BrowserTopBarTest` (4) | | `BrowserTopBar.kt` | 238 | The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | `BrowserTopBarTest` (4) |
| `BrowserUiState.kt` | 379 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, PickerFolder, TransferPicker, MessageAction, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - | | `BrowserUiState.kt` | 391 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, CompressRequest, PickerFolder, TransferPicker, MessageAction, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - |
| `BrowserViewModel.kt` | 2430 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (102) | | `BrowserViewModel.kt` | 2540 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (113) |
| `DetailsSheet.kt` | 109 | Name, location, size, type and modified date for one entry. DetailsSheet(), DetailLines(), DetailLine() | - | | `DetailsSheet.kt` | 109 | Name, location, size, type and modified date for one entry. DetailsSheet(), DetailLines(), DetailLine() | - |
| `EntryIntents.kt` | 58 | Wraps an entry's path in a content:// URI another app may read. contentUriFor(), viewIntent(), shareIntent() | - | | `EntryIntents.kt` | 58 | Wraps an entry's path in a content:// URI another app may read. contentUriFor(), viewIntent(), shareIntent() | - |
| `IncomingBar.kt` | 60 | The bar shown while another app is waiting on this one. IncomingBar() | - | | `IncomingBar.kt` | 60 | The bar shown while another app is waiting on this one. IncomingBar() | - |
@@ -139,7 +140,7 @@ The trash screen
| file | lines | what it holds | tests | | file | lines | what it holds | tests |
|---|---|---|---| |---|---|---|---|
| `TrashScreen.kt` | 192 | What the trash still holds, and the way to put it back. TrashScreen(), BatchHeader(), TrashEntryRow() | - | | `TrashScreen.kt` | 220 | What the trash still holds, and the way to put it back. TrashScreen(), BatchHeader(), TrashEntryRow() | - |
## ui/duplicates/ ## ui/duplicates/
@@ -164,7 +165,7 @@ Compose theme, colours, type
| file | lines | what it holds | tests | | file | lines | what it holds | tests |
|---|---|---|---| |---|---|---|---|
| `FileManagerApplication.kt` | 151 | Application entry point and the app's tiny object graph. FileManagerApplication | - | | `FileManagerApplication.kt` | 151 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
| `MainActivity.kt` | 65 | MainActivity | - | | `MainActivity.kt` | 69 | MainActivity | - |
## benchmark/ ## benchmark/
@@ -279,6 +280,11 @@ Every test name in the suite, by file: the fastest way to see whether something
- treats a dotfile as having no extension - treats a dotfile as having no extension
- preserves the base of multi-dot names - preserves the base of multi-dot names
### `core/ShortcutRouteTest.kt` (2)
- each shortcut key maps to its route
- anything else is ignored rather than thrown
### `core/SortSpecTest.kt` (8) ### `core/SortSpecTest.kt` (8)
- directories come first in both directions - directories come first in both directions
@@ -427,9 +433,10 @@ Every test name in the suite, by file: the fastest way to see whether something
- a volume without a space reading contributes nothing - a volume without a space reading contributes nothing
- used bytes never go negative - used bytes never go negative
### `domain/ArchiveEngineTest.kt` (30) ### `domain/ArchiveEngineTest.kt` (31)
- a zip round-trips folders and files - a zip round-trips folders and files
- an uncompressed zip round-trips and is larger than a squeezed one
- createZip reports progress over the files it will write - createZip reports progress over the files it will write
- a zip-slip entry cannot escape the destination - a zip-slip entry cannot escape the destination
- a single-file gzip is unwrapped to its inner name - a single-file gzip is unwrapped to its inner name
@@ -521,8 +528,9 @@ Every test name in the suite, by file: the fastest way to see whether something
- the timestamp is stamped from the clock when the pass finishes - the timestamp is stamped from the clock when the pass finishes
- a second pass replaces the first rather than doubling it - a second pass replaces the first rather than doubling it
### `domain/TrashTest.kt` (19) ### `domain/TrashTest.kt` (20)
- emptying removes every batch and leaves what is not one
- staging moves an item aside and records where it came from - staging moves an item aside and records where it came from
- restoring puts an item back exactly where it was - restoring puts an item back exactly where it was
- a folder comes back whole - a folder comes back whole
@@ -559,6 +567,12 @@ Every test name in the suite, by file: the fastest way to see whether something
- a determinate operation shows the counter - a determinate operation shows the counter
- starting the service puts it in the foreground with that title - starting the service puts it in the foreground with that title
### `ui/browser/BrowserComponentsTest.kt` (3)
- a row an operation produced carries the mark
- an ordinary row carries no mark
- a grid cell an operation produced carries the mark too
### `ui/browser/BrowserDialogsTest.kt` (5) ### `ui/browser/BrowserDialogsTest.kt` (5)
- the batch dialog spells the names out before anything moves - the batch dialog spells the names out before anything moves
@@ -581,7 +595,7 @@ Every test name in the suite, by file: the fastest way to see whether something
- a library or the root list has nothing to star - a library or the root list has nothing to star
- the star gives way to the search field - the star gives way to the search field
### `ui/browser/BrowserViewModelTest.kt` (102) ### `ui/browser/BrowserViewModelTest.kt` (113)
- starts on the root list - starts on the root list
- opening a folder lists it with directories first and natural order - opening a folder lists it with directories first and natural order
@@ -601,6 +615,17 @@ Every test name in the suite, by file: the fastest way to see whether something
- a paste into the folder on screen names no destination and offers no action - a paste into the folder on screen names no destination and offers no action
- the message's undo action is the undo - the message's undo action is the undo
- a move to a chosen folder offers undo rather than show - a move to a chosen folder offers undo rather than show
- a copy into the folder on screen marks the copy
- a copy into another folder marks it there, not here
- reading the folder again spends its marks
- a rename marks the name it produced
- compressing asks for a name and a choice before writing
- a confirmed compress writes the name that was typed, and marks it
- cancelling the compress dialog writes nothing
- opening the trash sweeps what has expired
- opening the trash keeps what is still inside the window
- emptying the trash removes what is in it
- a shortcut opens the view it names
- navigating up goes to the parent then to the roots - navigating up goes to the parent then to the roots
- selection survives toggling and clears on navigation - selection survives toggling and clears on navigation
- a file with no preview of its own is handed to the system - a file with no preview of its own is handed to the system
@@ -698,4 +723,4 @@ Every test name in the suite, by file: the fastest way to see whether something
--- ---
Purpose taken from the first KDoc rather than from a declaration sharing the file's name (15 of 78): `MainActivity.kt`, `ArchiveFormats.kt`, `FileNames.kt`, `ApkInspector.kt`, `OperationModels.kt`, `TextRes.kt`, `BrowserComponents.kt`, `BrowserDetailPane.kt`, `BrowserDialogs.kt`, `BrowserRoots.kt`, `BrowserScreen.kt`, `EntryIntents.kt`, `Color.kt`, `Theme.kt`, `Type.kt` Purpose taken from the first KDoc rather than from a declaration sharing the file's name (14 of 79): `MainActivity.kt`, `ArchiveFormats.kt`, `FileNames.kt`, `ApkInspector.kt`, `OperationModels.kt`, `TextRes.kt`, `BrowserComponents.kt`, `BrowserDetailPane.kt`, `BrowserDialogs.kt`, `BrowserRoots.kt`, `EntryIntents.kt`, `Color.kt`, `Theme.kt`, `Type.kt`
+114
View File
@@ -26,6 +26,12 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| M13 | Video thumbnails | ✅ | A video row shows **a frame of the video** instead of a film icon: images and videos take the same path through Coil, with the video request asking for a frame a second in. One dependency — `coil-video` at the Coil version already pinned, whose POM adds only coil-core/stdlib/core-ktx — the platform's own `MediaMetadataRetriever` behind it, **no new permission** (no `READ_MEDIA_VIDEO`), and the decoder registered **explicitly** against an `ImageLoader` in `FileManagerApplication` — because R8 strips `coil-video`'s ServiceLoader entry from a release build, which would otherwise have left frames decoding in debug and quietly falling back to icons for real users. A decode that fails settles on the icon rather than an empty square. Verified on the phone against two purpose-made clips and a real video of the device's own, with the demo library's stand-ins falling back as intended, and **re-verified on the release APK**. **324 JVM tests** | | M13 | Video thumbnails | ✅ | A video row shows **a frame of the video** instead of a film icon: images and videos take the same path through Coil, with the video request asking for a frame a second in. One dependency — `coil-video` at the Coil version already pinned, whose POM adds only coil-core/stdlib/core-ktx — the platform's own `MediaMetadataRetriever` behind it, **no new permission** (no `READ_MEDIA_VIDEO`), and the decoder registered **explicitly** against an `ImageLoader` in `FileManagerApplication` — because R8 strips `coil-video`'s ServiceLoader entry from a release build, which would otherwise have left frames decoding in debug and quietly falling back to icons for real users. A decode that fails settles on the icon rather than an empty square. Verified on the phone against two purpose-made clips and a real video of the device's own, with the demo library's stand-ins falling back as intended, and **re-verified on the release APK**. **324 JVM tests** |
| M14 | Batch rename with sequence continuation | ✅ | Selecting more than one file and choosing Rename opens a **pattern** dialog: a name, a start number, and a live preview of the names it will produce. `core/BatchRename` plans the whole batch before a single file moves and returns one of three answers — **Ready**, **Blocked** (naming the target the folder already holds) or **NothingToDo** — with the deliberate absence of a fourth: a batch that renamed nine of twelve and stopped would leave the user to work out which nine. The numbering **continues the folder's own series** `nextNumber` from the highest `base (n)` it finds, which is the market survey's most-helpful complaint answered by name; the extension is kept per file, the series is counted across extensions, and continuing beats filling the first gap. `SelectionBar` now offers Rename to any selection (one file keeps the name dialog, several get the pattern one), and the batch's renames are **one** `Undo.Relocate` step. The same commit hardens the single rename: `core/FileNames.isUsableName` refuses a name that is not one path segment, because `notes/final.txt` was being resolved as a path and **moved** the file instead of renaming it. **350 JVM tests** (26 added) | | M14 | Batch rename with sequence continuation | ✅ | Selecting more than one file and choosing Rename opens a **pattern** dialog: a name, a start number, and a live preview of the names it will produce. `core/BatchRename` plans the whole batch before a single file moves and returns one of three answers — **Ready**, **Blocked** (naming the target the folder already holds) or **NothingToDo** — with the deliberate absence of a fourth: a batch that renamed nine of twelve and stopped would leave the user to work out which nine. The numbering **continues the folder's own series** `nextNumber` from the highest `base (n)` it finds, which is the market survey's most-helpful complaint answered by name; the extension is kept per file, the series is counted across extensions, and continuing beats filling the first gap. `SelectionBar` now offers Rename to any selection (one file keeps the name dialog, several get the pattern one), and the batch's renames are **one** `Undo.Relocate` step. The same commit hardens the single rename: `core/FileNames.isUsableName` refuses a name that is not one path segment, because `notes/final.txt` was being resolved as a path and **moved** the file instead of renaming it. **350 JVM tests** (26 added) |
| M15 | Favourites | ✅ | A star in the app bar of any folder, and a **Favourites** section on the home screen above the libraries — absent while empty, so a first-time user still meets the libraries first. `data/BookmarkStore` + `SqliteBookmarkStore` keep one path per row in their own database: a **reference, never a copy**, so the list costs one query and no filesystem work. `BrowserUiState.Bookmark` carries an `exists` flag, and a folder that has gone is **kept and marked** rather than deleted — an unmounted card and a deleted folder are the same thing from here — with opening re-checked at that moment so the answer is a message instead of an empty listing. The row's filled star is the removal (the mark that says "favourite" is the thing you tap to stop it being one), there is no star in a library or on the root screen, and no `Undo` for a bookmark because that type is "the last change to the filesystem" and a favourite is not one. **371 JVM tests** (16 added) | | M15 | Favourites | ✅ | A star in the app bar of any folder, and a **Favourites** section on the home screen above the libraries — absent while empty, so a first-time user still meets the libraries first. `data/BookmarkStore` + `SqliteBookmarkStore` keep one path per row in their own database: a **reference, never a copy**, so the list costs one query and no filesystem work. `BrowserUiState.Bookmark` carries an `exists` flag, and a folder that has gone is **kept and marked** rather than deleted — an unmounted card and a deleted folder are the same thing from here — with opening re-checked at that moment so the answer is a message instead of an empty listing. The row's filled star is the removal (the mark that says "favourite" is the thing you tap to stop it being one), there is no star in a library or on the root screen, and no `Undo` for a bookmark because that type is "the last change to the filesystem" and a favourite is not one. **371 JVM tests** (16 added) |
| M16 | Copy to… / Move to… | ✅ | A destination picker behind the selection bar's overflow — volumes, then folders one level at a time, with Up and the trail above them — that copies or moves **without leaving the folder you are in**. The selection survives a copy, so a second destination is two taps; a move clears it. Conflicts ask what a paste asks, naming the picked folder; a copy answers "Copied to *folder*" with **Show**. Detailed below |
| M17 | The mark of a change | ✅ | A dot on the rows an operation produced, spent by the next read of that folder. In memory, per folder: no timer, no watcher, no badge. `BrowserUiState.changed` + `markChanged` on every operation that creates something; the dot is drawn by `MarkOfChange` in both list and grid, with a content description TalkBack reads. **7 tests** (4 view model, 3 Compose) |
| M18 | Store-only archives | ✅ | Compressing asks two things: the name, and whether to compress. Off stores the files as they are — `Deflater.NO_COMPRESSION`, one pass — which is what a bulk copy off the phone actually wants. A `STORED` entry would need each file's size and CRC before its header, i.e. reading everything twice. **4 tests** |
| M19 | The trash says what it does | ✅ | The trash states its window ("Deleted files are kept for 24 hours") and offers **Empty trash** behind one confirmation; opening the trash sweeps expired batches, so the 24-hour retention no longer waits for the next delete to run. **4 tests** |
| M20 | Launcher shortcuts | ✅ | Images, Recent and Trash as static shortcuts — three intents with a route extra, `core/ShortcutRoute` answering `null` for anything it does not know. Favourites was dropped from the plan: it is a section of the home screen, not a view. **3 tests** |
| M21 | Android TV | ⏳ | Specified below and deliberately not started: its acceptance needs a TV device pass this machine cannot provide. |
## Key decisions ## Key decisions
@@ -69,6 +75,10 @@ scaling numbers (which turned into progressive listing), Copy to… / Move to…
left is the baseline profile's *measurement*, which needs a physical API 33+ device and cannot be finished left is the baseline profile's *measurement*, which needs a physical API 33+ device and cannot be finished
on this machine; everything else in the list says what it produced. on this machine; everything else in the list says what it produced.
**M17–M20** come from the same survey's remaining gaps and are implemented (the change mark, store-only
archives, a trash that says what it does, launcher shortcuts). **M21** (Android TV) is specified and
deliberately not started: its acceptance needs a TV device pass this machine cannot provide.
1. **Baseline profile** — **generated and committed**: `app/src/main/baseline-prof.txt` is bundled in the 1. **Baseline profile** — **generated and committed**: `app/src/main/baseline-prof.txt` is bundled in the
release APK, written by the `:baselineprofile` module. What remains is its *measurement*: collection release APK, written by the `:baselineprofile` module. What remains is its *measurement*: collection
needs a device with API 33+, and the phone is Android 12, while the API 35 emulator here is too noisy to needs a device with API 33+, and the phone is Android 12, while the API 35 emulator here is too noisy to
@@ -131,3 +141,107 @@ destination picker.
**Not in scope**: drag and drop, a two-pane phone layout, remembering recent destinations. **Not in scope**: drag and drop, a two-pane phone layout, remembering recent destinations.
### M17 — The mark of a change
**What**: the rows an operation created carry a dot until the folder is read again, in both the list and the
grid.
**Why this shape**: the survey's 3.6.5 ("temporarily highlight new files with a dot after file operations")
answers an obvious question — *what did that 4 GB copy just do?* — and it got sharper with M16, because a copy
can now land in a folder the user is not looking at. The dot is a **set of paths the view model marks when an
operation lands**, not a timestamp on every row: no timer, no background job, and "temporarily" means "until
you see the folder again". The marks live in memory, so a restart forgets them — the same lifetime as the
clipboard, and for the same reason.
**Acceptance criteria**
- Copying, moving, compressing, extracting, renaming and creating a folder or file mark what they produced.
- The dot is drawn in the list and in the grid and carries a content description, so TalkBack says it and a
Compose test can find it.
- Opening the folder again clears its marks; opening a *different* folder does not disturb them.
- Tests: a copy into the current folder marks the copy and no untouched row; a copy into another folder marks
it there; re-opening clears.
**Not in scope**: catching files that appear while the folder is open (that needs a watcher), a notification
or a badge on the home screen.
### M18 — Store-only archives
**What**: compressing asks two things — the name, and whether to compress — because the survey's most
enthusiastic review is a 150 GB push to a PC, and that push is faster as one file that was cheap to build.
**Why this shape**: we refuse `INTERNET`, so their FTP-server answer is out; what is left is to make the file
that leaves as cheap as possible. `Deflater.NO_COMPRESSION` does that in one pass. A `STORED` zip entry would
be more literal and needs each file's size and CRC *before* the entry header is written, i.e. reading every
file twice — slower than the deflate it avoids.
**Acceptance criteria**
- `ArchiveEngine.createZip` takes the choice; the archive is a valid zip either way and extracts to identical
bytes.
- The dialog opens on "compress contents" and does not remember the last answer.
- Tests: both variants round-trip; the uncompressed one is measurably larger; the view model passes the flag.
**Not in scope**: a remembered preference, an estimated time, choosing a compression *level*.
### M19 — The trash says what it does
**What**: the trash screen states the window (24 hours) and offers **Empty trash**; the expiry sweep runs when
the trash is opened, not only after the next delete.
**Why this shape**: the retention already exists (`Trash.RETENTION_HOURS`) and is invisible, and
`purgeExpired` runs *after staging* — so a user who deletes once and never again keeps a day-old batch
forever. Those are the two halves of "why is my storage full" that the app itself can answer. Making the
window configurable would need a settings surface this app does not have; saying what it is costs nothing.
**Acceptance criteria**
- Opening the trash sweeps expired batches first, and the screen says how long items are kept.
- **Empty trash** asks once, then deletes every batch for good — it is the one destructive action in the app
with no undo, because that is what emptying means.
- Tests: an expired batch is gone after opening, a fresh one survives, emptying removes all and leaves the
screen saying it is empty.
**Not in scope**: a per-item permanent delete, a configurable window, a size cap.
### M20 — Launcher shortcuts
**What**: long-press the app icon → **Images**, **Recent**, **Trash**.
**Why this shape**: static shortcuts need no permission and no database — three intents with an extra, mapped
to views that are otherwise two or three taps deep. It is the cheapest route to the screens the survey says
people install the app for.
**Recent, not Favourites** — the one change from the proposal above. Favourites is a *section of the home
screen* rather than a view of its own, so a shortcut to it would open the home screen and promise a section
that is absent when nothing is starred. Recent is a library with its own name and its own state, so the
shortcut opens exactly what it says.
**Acceptance criteria**
- Three shortcuts in `res/xml/shortcuts.xml`; each opens the app directly at that view, and back from there
behaves like any other entry.
- An unknown or absent extra is ignored, so an old shortcut cannot crash a new build.
- Tests: the route parser maps the three names and returns `null` for anything else; each route opens the
right view.
**Not in scope**: dynamic shortcuts built from favourites, deep links to a path.
### M21 — Android TV (specified, not started)
**What**: a leanback-capable build — banner, launcher entry, and the whole browser and every dialog reachable
with a d-pad.
**Why this shape, and why it is not done**: Android TV is the only genuinely *new market* the survey names that
costs no permission, and it is also the one milestone whose acceptance cannot be met on this machine — there
is no TV AVD here and no KVM to run one at a usable speed. A half-tested TV build reviews worse than no TV
build, which is the survey's own lesson about the competitor's timeouts, so the plan is written down and the
work waits for a device.
**Acceptance criteria (when it starts)**: `LEANBACK_LAUNCHER` intent filter and a banner; focus traversal
through the browser, the selection bar, the transfer picker and every dialog; no touch-only affordance (long
press and double tap need a d-pad equivalent); a TV emulator pass recorded in [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md).
**Not in scope**: TV-specific layouts, gamepad support, a screensaver.