Rename a whole batch at once, numbered on from the folder's own series
Selecting more than one file and choosing Rename now opens a pattern dialog rather than the
single-file one: a name, the number to start at, and a live preview of the names it will produce.
The number is the part that makes the feature worth having. A batch rename that always starts at 1
is useless the second time it is used, because the first batch already owns 1 to 12, so the dialog
reads the names already in the folder and starts at the highest "base (n)" it finds: a folder
holding "series (1)" and "series (2)" makes the next batch start at 3, which is the market survey's
most-helpful complaint answered exactly ("if I later find another group of photos which I want to
continue the batch naming with 'Ice (3)...'"). Three rules in core/BatchRename are decisions rather
than defaults, and are written down as such: the extension is kept per file, so a mixed batch of
.jpg and .png keeps each file's own; the series is counted across extensions, because "Holiday
(9).png" making the next number 10 is one series while "Holiday (10).jpg" beside "Holiday (1).png"
is two; and it continues from the highest number rather than from the first gap, because someone
renaming a second batch is carrying a series on rather than filling in the holes of one, with the
start number theirs to type where a gap matters more.
The batch is planned in full before a single file moves, and the plan has three answers and
deliberately no fourth: Ready, Blocked (naming the target that is already in the folder) or
NothingToDo. A batch that renamed nine files of twelve and then stopped would leave the user to work
out which nine and to guess whether the last three crashed or were skipped, so a collision refuses
the whole batch, the dialog names the file in the way and disables its button as the name is typed,
and a chain is declined outright - "a.txt" taking "a (1).txt" while "a (1).txt" is itself in the
batch would need the files shuffled through temporary names. Every rename still goes through the
same FileOperations.rename a single rename uses, so the refusal to overwrite anything is the same
one, and the successful renames are collected into the single Undo.Relocate step that already
existed: a feature that touches twelve files at once owes the user one tap back. The action bar now
offers Rename to any selection, since which of the two dialogs a tap means is described by the
dialog that opens rather than by the bar; Details stays single, because "the details" of twelve
files is not a screen.
Writing it turned up a quieter bug in the rename that was already there. rename(path,
"notes/final.txt") built a child path, which resolves into the subfolder rather than alongside the
file, so a name containing a separator moved the file instead of renaming it and nothing in the
dialog or the listing showed that anything had happened. Both rename paths now refuse a name that is
not one path segment (core/FileNames.isUsableName), and the dialog says which rule it is while it is
typed, which is where the user can still do something about it.
Twenty new core tests (BatchRenameTest, FileNamesTest) and six through the view model, 350 JVM
tests in total, lint clean. Verified by driving it on the phone against a folder that already held
"series (1).txt" and "series (2).txt": with three files selected and "series" typed in, Start at
moved to 3 on its own and the preview listed "series (3).txt, series (4).txt, series (5).txt";
afterwards the folder held all five series files, each file's contents having travelled with its
rename and the two originals untouched, and the snackbar's Undo put a later batch of three back in
one step. The collision and the slash were driven the same way - the file in the way named with the
button disabled, and "A name cannot contain a slash" - and the scratch folder was removed from the
device afterwards. CHANGELOG, ROADMAP (M14), COMPETITIVE_ANALYSIS (the gap moves from candidate to
closed), STORE_LISTING and EDGE_CASE_TESTING carry the feature, its decisions and the pass itself;
two tooling notes went into the last of them, that "adb input text" does not deliver a slash and
that the dialog lifts once the keyboard appears.
This commit is contained in:
@@ -10,6 +10,51 @@ upload needs a **higher `versionCode`** than every previous upload.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Several files can be renamed at once, numbered in a series that carries on.** Selecting more
|
||||
than one file now offers Rename in the action bar, which opens a **pattern** dialog instead of the
|
||||
single-file one: a name for the batch, the number to start at, and a live preview of the names it
|
||||
will produce — *Will be renamed to Holiday (1).jpg, Holiday (2).jpg, Holiday (3).jpg* — because a
|
||||
batch is the one rename whose result cannot be read off its input. **The number is the point.** A
|
||||
batch that always starts at 1 is useless the second time it is used, so `core/BatchRename` reads
|
||||
the names already in the folder and starts at the highest one it finds: `series (1)` and
|
||||
`series (2)` from an earlier batch make the next one start at 3, which is exactly the case the
|
||||
market survey's most-helpful complaint describes ("if I later find another group of photos which I
|
||||
want to continue the batch naming with 'Ice (3)…'"). Three rules are written in the source as
|
||||
decisions rather than defaults, because each one changes what a user ends up with: the extension is
|
||||
kept **per file** (a mixed batch of `.jpg` and `.png` keeps each file's own, since a pattern names
|
||||
the file and not its type), the series is counted **across extensions** (`Holiday (9).png` makes
|
||||
the next number 10 for a batch of `.jpg`s — counting per extension would put `Holiday (10).jpg` and
|
||||
`Holiday (1).png` in one folder while calling them the same series), and it continues from the
|
||||
**highest** number rather than from the first gap, because someone renaming a second batch is
|
||||
carrying a series on rather than filling in the holes of one; where a gap matters more, the start
|
||||
number is theirs to type.
|
||||
|
||||
A batch is refused as a whole rather than performed until it hits the problem, because it cannot
|
||||
be *partly* right: nine files renamed of twelve would leave the user to work out which nine, and
|
||||
to guess whether the last three crashed or were skipped. So a target the folder already holds
|
||||
blocks the batch and is **named** as it is typed (*series (1).txt is already in this folder*), with
|
||||
the Rename button disabled; a selection whose files already hold the names asked for says so
|
||||
instead of doing nothing; and a *chain* is declined outright — `a.txt` taking `a (1).txt` while
|
||||
`a (1).txt` is itself in the batch would need the files shuffled through temporary names, and
|
||||
refusing plainly beats doing it half way. Every rename in the batch goes through the same
|
||||
`FileOperations.rename` a single rename uses, so nothing is ever overwritten, and the successful
|
||||
ones are collected into the **one** `Undo.Relocate` step the single rename already used.
|
||||
|
||||
Found while writing it, and fixed with it: **a rename could be a path.** `rename(path,
|
||||
"notes/final.txt")` built a child path that resolved *into* a subfolder, so a name containing a
|
||||
separator quietly moved the file instead of renaming it — with nothing in the dialog or the listing
|
||||
to show for it. Both the single rename and the batch now refuse a name that is not one path segment
|
||||
(`core/FileNames.isUsableName`), and the dialog says which rule it is. **20 new core tests**
|
||||
(`BatchRenameTest`, `FileNamesTest`) and 6 through the view model, **350 JVM tests** in total.
|
||||
Verified by driving it on the Xiaomi phone against a folder that already held `series (1).txt` and
|
||||
`series (2).txt`: with three files selected and `series` typed in, **Start at** moved to 3 on its
|
||||
own and the preview listed `series (3).txt, series (4).txt, series (5).txt`; afterwards the folder
|
||||
held all five, each file's contents having travelled with it, the two originals untouched — and the
|
||||
snackbar's **Undo** put the later batch of three back in one step. The collision and the slash were
|
||||
checked the same way, both landing with the action disabled and the reason named.
|
||||
|
||||
### Changed
|
||||
|
||||
- **File thumbnails are bigger.** The leading visual in a list row went from 28 dp to 48 dp and
|
||||
|
||||
@@ -12,10 +12,15 @@ no advertising or tracking SDKs.
|
||||
|
||||
## Status
|
||||
|
||||
**M13 — video thumbnails: done.** A video row shows a frame of the video itself rather than a film
|
||||
icon — the platform's own retriever, through Coil, with a frame taken a second in and the icon kept
|
||||
as the fallback when a file cannot be decoded. Before it: **M12 — duplicate finder** (size, then the
|
||||
head of every candidate, then the survivors in full), **M11 — library views** (a **Libraries**
|
||||
**M14 — renaming a batch: done.** Selecting several files and choosing Rename opens a **pattern**
|
||||
dialog instead of the single-file one: one name, the number to start at, and a preview of the names
|
||||
it will produce — and the numbering **carries on from the folder's own**, so `series (1)` and
|
||||
`series (2)` already there make the next batch start at 3. A batch that cannot be done is refused
|
||||
before anything moves, naming the file in the way, and the whole batch is one undo step. Before it:
|
||||
**M13 — video thumbnails** (a video row shows a frame of the video itself rather than a film icon —
|
||||
the platform's own retriever, through Coil, with a frame taken a second in and the icon kept as the
|
||||
fallback when a file cannot be decoded), **M12 — duplicate finder** (size, then the head of every
|
||||
candidate, then the survivors in full), **M11 — library views** (a **Libraries**
|
||||
section on the root screen — Images, Videos, Audio, Documents, Archives and Recent — whose rows open
|
||||
ordinary listings of those files, each carrying a count from the search index), and **M10 — store
|
||||
assets**: the app is named **Plain Files**, the icon and feature graphic come from the app's own
|
||||
@@ -174,6 +179,11 @@ UI.** Translated UI text has to be supplied in `values-<locale>/`.
|
||||
no archive encryption or password support.
|
||||
- **Cancellation happens between entries**, so a single very large file finishes before a
|
||||
stop is honoured.
|
||||
- **A batch rename is refused rather than half-done when one of its names is taken**, including when
|
||||
the file holding that name is part of the same batch: `a.txt` taking `a (1).txt` while `a (1).txt`
|
||||
is itself being renamed needs the files shuffled through temporary names, so the dialog names the
|
||||
file in the way instead. Renaming in the other order, or starting the numbering past the block,
|
||||
does what you want.
|
||||
- **A library is not restored across process death.** The persisted view state holds a path, and a
|
||||
library has none, so a restart returns to the root screen rather than to Images or Recent.
|
||||
- **Long-press multi-select is awkward to script from the host.** The gesture that opens the
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package de.softwareapp_hb.filemanager.core
|
||||
|
||||
/** One file's part in a batch rename: where it is now, and the name it should end up with. */
|
||||
data class BatchRenameMove(val from: StoragePath, val to: String)
|
||||
|
||||
/**
|
||||
* What a batch rename would do, worked out in full before anything is touched.
|
||||
*
|
||||
* The three outcomes are the three things that can be true of a pattern: it renames everything
|
||||
* ([Ready]), it asks for a name that is already there ([Blocked]), or every file already has the
|
||||
* name it asks for ([NothingToDo]). There is deliberately no fourth case in which the work is
|
||||
* *partly* possible: a batch that renamed nine files of twelve and then stopped would leave the
|
||||
* user to work out which nine, and to guess whether the last three crashed or were skipped.
|
||||
*/
|
||||
sealed interface BatchRenamePlan {
|
||||
/** The renames to perform, in the order the files were shown, and how many stay as they are. */
|
||||
data class Ready(val moves: List<BatchRenameMove>, val unchanged: Int) : BatchRenamePlan
|
||||
|
||||
/** Names the pattern asks for that something else in the folder already holds. */
|
||||
data class Blocked(val taken: List<String>) : BatchRenamePlan
|
||||
|
||||
/** Every selected file is already named what the pattern asks for. */
|
||||
data object NothingToDo : BatchRenamePlan
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a batch of files to one name with a number after each: `Holiday (1).jpg`,
|
||||
* `Holiday (2).jpg`, and so on.
|
||||
*
|
||||
* **The number is the point.** A batch rename that always starts at 1 is useless the second time it
|
||||
* is used — the first batch owns 1 to 12 and the next has nowhere to go — which is exactly what the
|
||||
* most-helpful complaint in the market survey describes. [nextNumber] reads the names already in the
|
||||
* folder so the next batch carries on from the highest number it finds rather than from one.
|
||||
*
|
||||
* Three rules are decisions rather than defaults, and are stated because they change what a user
|
||||
* gets:
|
||||
*
|
||||
* - **The extension is kept, per file.** A batch of mixed `.jpg` and `.png` keeps each file's own
|
||||
* extension, because a pattern names the file and not its type.
|
||||
* - **The series is counted across extensions.** `Holiday (9).png` makes the next number 10 for a
|
||||
* batch of `.jpg` files. Counting per extension would put `Holiday (10).jpg` and
|
||||
* `Holiday (1).png` in one folder while calling them the same series.
|
||||
* - **It continues from the highest number, not from the first gap.** A folder holding `Ice (1)` and
|
||||
* `Ice (9)` continues at 10: someone renaming a second batch is carrying a series on, not filling
|
||||
* in the holes in one. Where a gap matters more than the series, the start number is theirs to
|
||||
* type.
|
||||
*/
|
||||
object BatchRename {
|
||||
|
||||
/**
|
||||
* The number a batch should start at, given the names already in the folder.
|
||||
*
|
||||
* A file named exactly like the base — `Holiday.jpg` — counts as number zero, so the next name
|
||||
* is `Holiday (1).jpg`, which is the shape the rest of the app already produces when it has to
|
||||
* step around a clash.
|
||||
*/
|
||||
fun nextNumber(base: String, namesInFolder: List<String>): Int {
|
||||
val pattern = Regex("^" + Regex.escape(base) + " \\((\\d+)\\)$")
|
||||
var highest = 0
|
||||
for (name in namesInFolder) {
|
||||
val stem = name.substring(0, extensionStart(name))
|
||||
// A file named exactly like the base is number zero, which `highest` already starts at.
|
||||
if (stem == base) continue
|
||||
val number = pattern.matchEntire(stem)?.groupValues?.get(1)?.toIntOrNull() ?: continue
|
||||
if (number > highest) highest = number
|
||||
}
|
||||
return highest + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out what renaming [entries] to [base] from [start] would do, in the order given.
|
||||
*
|
||||
* The order is the caller's — the listing order, which is the order the user is looking at — so
|
||||
* the first file shown becomes number [start].
|
||||
*/
|
||||
fun plan(
|
||||
base: String,
|
||||
start: Int,
|
||||
entries: List<FileEntry>,
|
||||
namesInFolder: List<String>,
|
||||
): BatchRenamePlan {
|
||||
// Numbering starts at one: there is no zeroth copy of anything.
|
||||
val first = start.coerceAtLeast(1)
|
||||
val taken = mutableListOf<String>()
|
||||
val moves = mutableListOf<BatchRenameMove>()
|
||||
var unchanged = 0
|
||||
|
||||
entries.forEachIndexed { index, entry ->
|
||||
val suffix = entry.name.substring(extensionStart(entry.name))
|
||||
val target = "$base (${first + index})$suffix"
|
||||
when {
|
||||
target == entry.name -> unchanged++
|
||||
|
||||
// Anything already in the folder blocks the whole batch — including one of the
|
||||
// selected files under its current name. A chain (`a.txt` taking the name
|
||||
// `a (1).txt` while `a (1).txt` is itself being renamed) would need the files
|
||||
// shuffled through temporary names to be done safely, and refusing it plainly is
|
||||
// better than doing it half way.
|
||||
target in namesInFolder -> taken += target
|
||||
|
||||
else -> moves += BatchRenameMove(entry.path, target)
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
taken.isNotEmpty() -> BatchRenamePlan.Blocked(taken)
|
||||
moves.isEmpty() -> BatchRenamePlan.NothingToDo
|
||||
else -> BatchRenamePlan.Ready(moves, unchanged)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [base] can be the name part of a pattern.
|
||||
*
|
||||
* The same rule a single rename needs, because a name is one path segment in both cases — see
|
||||
* [isUsableName].
|
||||
*/
|
||||
fun isUsableBase(base: String): Boolean = isUsableName(base)
|
||||
|
||||
/** Where a name's extension begins, or its length when it has none — `.hide` has none. */
|
||||
private fun extensionStart(name: String): Int {
|
||||
val dot = name.lastIndexOf('.')
|
||||
return if (dot <= 0) name.length else dot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.softwareapp_hb.filemanager.core
|
||||
|
||||
/**
|
||||
* Whether [name] can be the name of a file in a folder.
|
||||
*
|
||||
* A name is **one path segment**: non-blank, and with no separator in it. That second half is not
|
||||
* pedantry. `StoragePath.child("notes/final")` resolves to `notes/final`, so a rename dialog that
|
||||
* accepted it would not rename anything — it would *move* the file into a subfolder that happens to
|
||||
* exist, which is not what the dialog offered. The check lives here rather than in the two dialogs
|
||||
* that need it so that "single rename" and "batch rename" cannot disagree about what a name is.
|
||||
*/
|
||||
fun isUsableName(name: String): Boolean =
|
||||
name.isNotBlank() && name.none { it == '/' || it == '\\' || it == '\u0000' }
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.softwareapp_hb.filemanager.ui.browser
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -14,12 +16,145 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.core.BatchRename
|
||||
import de.softwareapp_hb.filemanager.core.BatchRenamePlan
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
|
||||
/**
|
||||
* Renaming several files at once, with the numbering worked out before anything moves.
|
||||
*
|
||||
* This is where the sequence continuation becomes visible: the number starts at whatever the
|
||||
* folder's own names imply, and a preview shows exactly which names that produces. Nothing is
|
||||
* renamed until Rename is pressed, and a name the folder already holds is shown as the reason the
|
||||
* button is off rather than discovered as a failure afterwards.
|
||||
*/
|
||||
@Composable
|
||||
fun BatchRenameDialog(
|
||||
entries: List<FileEntry>,
|
||||
/** The names already in this folder, so the numbering can carry an existing series on. */
|
||||
namesInFolder: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (base: String, start: Int) -> Unit,
|
||||
) {
|
||||
var base by remember { mutableStateOf("") }
|
||||
|
||||
/*
|
||||
* `null` until the user types a number, and until then the field follows what the folder
|
||||
* implies — so a batch that continues a series needs no arithmetic from the user at all. Once a
|
||||
* number has been typed it is theirs and stops following the name, because a start that jumped
|
||||
* as they edited the name would be worse than one they have to correct.
|
||||
*/
|
||||
var typedStart by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val startText = typedStart ?: BatchRename.nextNumber(base, namesInFolder).toString()
|
||||
val start = startText.toIntOrNull()
|
||||
val plan = start?.let { BatchRename.plan(base, it, entries, namesInFolder) }
|
||||
val usable = BatchRename.isUsableBase(base)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.browser_rename_many_title,
|
||||
entries.size,
|
||||
entries.size,
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = base,
|
||||
onValueChange = { base = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = base.isNotEmpty() && !usable,
|
||||
label = { Text(stringResource(R.string.browser_name_label)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = startText,
|
||||
// Digits only: a number field that accepts anything and then rejects it is a
|
||||
// field that lies about what it wants.
|
||||
onValueChange = { typed -> typedStart = typed.filter { it.isDigit() } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.browser_rename_many_start)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = batchRenameNote(base = base, usable = usable, plan = plan),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (plan is BatchRenamePlan.Ready) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (start != null) onConfirm(base.trim(), start) },
|
||||
enabled = usable && plan is BatchRenamePlan.Ready,
|
||||
) {
|
||||
Text(stringResource(R.string.browser_action_rename_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.browser_action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** How many of the resulting names the dialog spells out before it stops counting them. */
|
||||
private const val PREVIEW_NAMES = 3
|
||||
|
||||
/**
|
||||
* What the dialog says under the fields: what the pattern will produce, or why it will not.
|
||||
*
|
||||
* Written as one function returning one line on purpose — the three ways a plan can fail are the
|
||||
* only things worth saying here, and the button's own state says the rest.
|
||||
*/
|
||||
@Composable
|
||||
private fun batchRenameNote(base: String, usable: Boolean, plan: BatchRenamePlan?): String = when {
|
||||
base.isEmpty() -> ""
|
||||
!usable -> stringResource(R.string.browser_rename_many_bad_name)
|
||||
|
||||
plan is BatchRenamePlan.Blocked ->
|
||||
stringResource(R.string.browser_rename_many_taken, plan.taken.first())
|
||||
|
||||
plan is BatchRenamePlan.NothingToDo -> stringResource(R.string.browser_rename_many_nothing)
|
||||
|
||||
plan is BatchRenamePlan.Ready -> {
|
||||
val shown = plan.moves.take(PREVIEW_NAMES).joinToString(", ") { it.to }
|
||||
val lead = stringResource(R.string.browser_rename_many_preview, shown)
|
||||
if (plan.moves.size > PREVIEW_NAMES) {
|
||||
lead + "\n" + stringResource(R.string.browser_rename_many_more, plan.moves.size - PREVIEW_NAMES)
|
||||
} else {
|
||||
lead
|
||||
}
|
||||
}
|
||||
|
||||
else -> ""
|
||||
}
|
||||
/** A single-field dialog, used for "New folder" and "Rename". */
|
||||
@Composable
|
||||
fun NameDialog(
|
||||
|
||||
@@ -97,6 +97,9 @@ fun BrowserScreen(
|
||||
var detailsFor by remember { mutableStateOf<FileEntry?>(null) }
|
||||
var showNewFolder by remember { mutableStateOf(false) }
|
||||
var renameTarget by remember { mutableStateOf<FileEntry?>(null) }
|
||||
|
||||
/** Several files at once: the selection, held while the pattern dialog is open. */
|
||||
var renameAllTargets by remember { mutableStateOf<List<FileEntry>?>(null) }
|
||||
var confirmDelete by remember { mutableStateOf<List<StoragePath>?>(null) }
|
||||
var searchOpen by remember { mutableStateOf(false) }
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
@@ -411,7 +414,16 @@ fun BrowserScreen(
|
||||
onCut = { viewModel.copyToClipboard(state.selection.toList(), cut = true) },
|
||||
onDelete = { confirmDelete = state.selection.toList() },
|
||||
onDetails = { detailsFor = state.selectedEntries.firstOrNull() },
|
||||
onRename = { renameTarget = state.selectedEntries.singleOrNull() },
|
||||
onRename = {
|
||||
val selected = state.selectedEntries
|
||||
// One file is renamed by name; several are renamed by pattern, which is the
|
||||
// only way a batch can be given a numbering at all.
|
||||
if (selected.size > 1) {
|
||||
renameAllTargets = selected
|
||||
} else {
|
||||
renameTarget = selected.singleOrNull()
|
||||
}
|
||||
},
|
||||
onCompress = { viewModel.compress(state.selection.toList()) },
|
||||
onExtract = {
|
||||
state.selectedEntries.singleOrNull()?.let(viewModel::extract)
|
||||
@@ -534,6 +546,21 @@ fun BrowserScreen(
|
||||
)
|
||||
}
|
||||
|
||||
renameAllTargets?.let { targets ->
|
||||
BatchRenameDialog(
|
||||
entries = targets,
|
||||
// The names this folder holds, so the numbering can carry its series on. Taken from the
|
||||
// listing on screen rather than from the storage again: the dialog must predict exactly
|
||||
// what the view model will do, and the view model reads the same listing.
|
||||
namesInFolder = state.entries.map { it.name },
|
||||
onDismiss = { renameAllTargets = null },
|
||||
onConfirm = { base, start ->
|
||||
renameAllTargets = null
|
||||
viewModel.renameAll(targets, base, start)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
confirmDelete?.let { paths ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.browser_delete_title),
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import de.softwareapp_hb.filemanager.FileManagerApplication
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.core.ArchiveFormats
|
||||
import de.softwareapp_hb.filemanager.core.BatchRename
|
||||
import de.softwareapp_hb.filemanager.core.BatchRenamePlan
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.FileCategory
|
||||
import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
@@ -22,6 +24,7 @@ import de.softwareapp_hb.filemanager.core.SortKey
|
||||
import de.softwareapp_hb.filemanager.core.SortSpec
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.core.TransferAction
|
||||
import de.softwareapp_hb.filemanager.core.isUsableName
|
||||
import de.softwareapp_hb.filemanager.data.ApkInspector
|
||||
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
|
||||
import de.softwareapp_hb.filemanager.data.IncomingSource
|
||||
@@ -1642,7 +1645,9 @@ class BrowserViewModel(
|
||||
val source = storage.activeSource() ?: return
|
||||
val parent = path.parent ?: return
|
||||
val cleanName = newName.trim()
|
||||
if (cleanName.isEmpty() || cleanName == path.name) return
|
||||
// A name is one path segment: a separator here would be read as a path and move the file
|
||||
// into a subfolder rather than rename it — see `isUsableName`.
|
||||
if (cleanName.isEmpty() || cleanName == path.name || !isUsableName(cleanName)) return
|
||||
|
||||
viewModelScope.launch {
|
||||
val renamed = runCatching { FileOperations(source).rename(path, parent.child(cleanName)) }
|
||||
@@ -1664,6 +1669,74 @@ class BrowserViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename several files to one name with a number after each — [BatchRename] works the numbering
|
||||
* out, this performs it.
|
||||
*
|
||||
* Two things are deliberate. **The plan is checked before a single file moves**, so a batch that
|
||||
* would collide is refused as a whole rather than performed until it hits the collision; the
|
||||
* dialog has already shown the same answer, and this is the view model refusing to be told
|
||||
* otherwise by a stale screen. And **every rename goes through the same
|
||||
* [FileOperations.rename] a single rename uses**, which is what keeps this from being a second,
|
||||
* subtly different way to rename a file — including its refusal to overwrite anything, so a
|
||||
* collision that happened between the plan and the work fails one file rather than destroying
|
||||
* one.
|
||||
*
|
||||
* The successful renames become **one** undo step: [Undo.Relocate] already holds a list of
|
||||
* moves, so a batch rename is as reversible as a single one, which is the least a feature that
|
||||
* touches twelve files at once owes the user.
|
||||
*/
|
||||
fun renameAll(entries: List<FileEntry>, base: String, start: Int) {
|
||||
val source = storage.activeSource() ?: return
|
||||
val parent = _state.value.path ?: return
|
||||
val cleanBase = base.trim()
|
||||
if (entries.size < 2 || !isUsableName(cleanBase)) return
|
||||
|
||||
// The listing on screen, which is the same list the dialog planned against: hidden files are
|
||||
// not in it, so a clash with one is not predicted here — it is refused by the rename itself
|
||||
// and reported as the one file that failed, rather than silently overwriting anything.
|
||||
val namesInFolder = _state.value.entries.map { it.name }
|
||||
val plan = when (val planned = BatchRename.plan(cleanBase, start, entries, namesInFolder)) {
|
||||
is BatchRenamePlan.Ready -> planned
|
||||
|
||||
is BatchRenamePlan.Blocked -> {
|
||||
message(R.string.browser_rename_many_taken, planned.taken.first())
|
||||
return
|
||||
}
|
||||
|
||||
BatchRenamePlan.NothingToDo -> {
|
||||
message(R.string.browser_rename_many_nothing)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val done = mutableListOf<UndoMove>()
|
||||
var failed = 0
|
||||
|
||||
for (move in plan.moves) {
|
||||
val target = parent.child(move.to)
|
||||
val renamed = runCatching { FileOperations(source).rename(move.from, target) }
|
||||
.getOrDefault(false)
|
||||
if (renamed) {
|
||||
done += UndoMove(from = target, to = move.from)
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
if (done.isNotEmpty()) {
|
||||
_state.update { it.copy(undo = Undo.Relocate(done)) }
|
||||
}
|
||||
when {
|
||||
failed == 0 -> messageUndoablePlural(R.plurals.message_renamed_many, done.size)
|
||||
done.isNotEmpty() -> messageUndoable(R.string.message_renamed_some, done.size, plan.moves.size)
|
||||
else -> messagePlural(R.plurals.message_failed, failed)
|
||||
}
|
||||
reloadCurrentView()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete [paths].
|
||||
*
|
||||
|
||||
@@ -122,10 +122,16 @@ fun SelectionBar(
|
||||
IconButton(onClick = onCut) {
|
||||
Icon(Icons.Filled.ContentCut, contentDescription = stringResource(R.string.browser_action_cut))
|
||||
}
|
||||
if (singled != null) {
|
||||
/*
|
||||
* Rename is offered for any selection: one file is renamed by name and several by
|
||||
* pattern, and which of the two a tap means is described by the dialog that opens, not
|
||||
* by the bar. Details stays single, because "the details" of twelve files is not a
|
||||
* screen.
|
||||
*/
|
||||
IconButton(onClick = onRename) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.browser_action_rename))
|
||||
}
|
||||
if (singled != null) {
|
||||
IconButton(onClick = onDetails) {
|
||||
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.browser_action_details))
|
||||
}
|
||||
|
||||
@@ -144,6 +144,28 @@
|
||||
<string name="browser_new_folder_title">New folder</string>
|
||||
<string name="browser_new_folder_default">New folder</string>
|
||||
<string name="browser_rename_title">Rename</string>
|
||||
<!-- ===== Browser: batch rename =====
|
||||
Renaming several files to one name with a number after each. The number is the point: a
|
||||
batch that always started at one would be useless the second time it was used. -->
|
||||
<plurals name="browser_rename_many_title">
|
||||
<item quantity="one">Rename %1$d file</item>
|
||||
<item quantity="other">Rename %1$d files</item>
|
||||
</plurals>
|
||||
<string name="browser_rename_many_start">Start at</string>
|
||||
<!-- %1$s lists the first few names the pattern produces, e.g. "Holiday (10).jpg, Holiday (11).jpg". -->
|
||||
<string name="browser_rename_many_preview">Will be renamed to %1$s</string>
|
||||
<!-- %1$d is how many further names there are beyond the few shown. -->
|
||||
<string name="browser_rename_many_more">… and %1$d more</string>
|
||||
<!-- %1$s is a name the pattern asks for that something in this folder already holds. -->
|
||||
<string name="browser_rename_many_taken">%1$s is already in this folder</string>
|
||||
<string name="browser_rename_many_nothing">Every selected file already has that name</string>
|
||||
<string name="browser_rename_many_bad_name">A name cannot contain a slash</string>
|
||||
<plurals name="message_renamed_many">
|
||||
<item quantity="one">Renamed %1$d file</item>
|
||||
<item quantity="other">Renamed %1$d files</item>
|
||||
</plurals>
|
||||
<!-- %1$d succeeded of %2$d attempted, when part of a batch could not be renamed. -->
|
||||
<string name="message_renamed_some">Renamed %1$d of %2$d files</string>
|
||||
<string name="browser_name_label">Name</string>
|
||||
|
||||
<!-- ===== Browser: details ===== -->
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.softwareapp_hb.filemanager.core
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The numbering rules, which is where this feature either earns its keep or does not: the rule a
|
||||
* user notices is the one that decides *where a second batch starts*, so most of this is about that.
|
||||
*/
|
||||
class BatchRenameTest {
|
||||
|
||||
private val folder = StoragePath.of("/storage/emulated/0/Pictures")
|
||||
|
||||
private fun file(name: String) = FileEntry(
|
||||
path = folder.child(name),
|
||||
isDirectory = false,
|
||||
sizeBytes = 1L,
|
||||
lastModifiedMillis = 0L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a second batch carries on where the first stopped`() {
|
||||
// The whole point of the feature, and the complaint it came from: "Ice (1), Ice (2)" today,
|
||||
// and no way to ask for "Ice (3), Ice (4)" tomorrow.
|
||||
val existing = listOf("Ice (1).jpg", "Ice (2).jpg", "Ice (3).jpg")
|
||||
|
||||
assertEquals(4, BatchRename.nextNumber("Ice", existing))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file named exactly like the base counts as number zero`() {
|
||||
assertEquals(1, BatchRename.nextNumber("Ice", listOf("Ice.jpg")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it continues from the highest number rather than from the first gap`() {
|
||||
// Carrying a series on, not filling holes in one.
|
||||
assertEquals(10, BatchRename.nextNumber("Ice", listOf("Ice (1).jpg", "Ice (9).jpg")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the series is counted across extensions`() {
|
||||
// Counting per extension would put Holiday (10).jpg and Holiday (1).png in one folder and
|
||||
// call them the same series.
|
||||
assertEquals(10, BatchRename.nextNumber("Ice", listOf("Ice (9).png")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `names that merely resemble the pattern are ignored`() {
|
||||
val existing = listOf(
|
||||
"Iceberg (9).jpg",
|
||||
"Ice (old).jpg",
|
||||
"Ice (9) copy.jpg",
|
||||
"Ice (9).jpg.bak",
|
||||
"Iced.jpg",
|
||||
)
|
||||
|
||||
assertEquals(1, BatchRename.nextNumber("Ice", existing))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a base that contains regex characters is matched literally`() {
|
||||
// Unescaped, the parentheses in the base would swallow the number and a second batch would
|
||||
// silently start again at 1, on top of the files that are already there.
|
||||
assertEquals(4, BatchRename.nextNumber("Ice (2024)", listOf("Ice (2024) (3).jpg")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty folder starts at one`() {
|
||||
assertEquals(1, BatchRename.nextNumber("Ice", emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plan keeps each file's own extension and numbers it in the order shown`() {
|
||||
val entries = listOf(file("a.JPG"), file("b.png"), file("notes"))
|
||||
val names = entries.map { it.name }
|
||||
|
||||
val ready = BatchRename.plan("Holiday", 1, entries, names) as BatchRenamePlan.Ready
|
||||
|
||||
assertEquals(
|
||||
listOf("Holiday (1).JPG", "Holiday (2).png", "Holiday (3)"),
|
||||
ready.moves.map { it.to },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("a.JPG", "b.png", "notes"),
|
||||
ready.moves.map { it.from.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plan can continue the folder's series`() {
|
||||
val entries = listOf(file("new-one.jpg"), file("new-two.jpg"))
|
||||
val existing = listOf("new-one.jpg", "new-two.jpg", "Ice (1).jpg", "Ice (2).jpg")
|
||||
|
||||
val start = BatchRename.nextNumber("Ice", existing)
|
||||
val ready = BatchRename.plan("Ice", start, entries, existing) as BatchRenamePlan.Ready
|
||||
|
||||
assertEquals(listOf("Ice (3).jpg", "Ice (4).jpg"), ready.moves.map { it.to })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a name that is already in the folder blocks the whole batch`() {
|
||||
val entries = listOf(file("a.jpg"), file("b.jpg"))
|
||||
val existing = listOf("a.jpg", "b.jpg", "Ice (1).jpg")
|
||||
|
||||
val blocked = BatchRename.plan("Ice", 1, entries, existing) as BatchRenamePlan.Blocked
|
||||
|
||||
assertEquals(listOf("Ice (1).jpg"), blocked.taken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chain through a selected file is refused rather than half-done`() {
|
||||
// Renaming a.txt onto a (1).txt while a (1).txt is itself being renamed needs the files
|
||||
// shuffled through temporary names. Refusing is honest; doing it half way is not.
|
||||
val entries = listOf(file("a.txt"), file("a (1).txt"))
|
||||
val existing = listOf("a.txt", "a (1).txt")
|
||||
|
||||
val blocked = BatchRename.plan("a", 1, entries, existing) as BatchRenamePlan.Blocked
|
||||
|
||||
assertEquals(listOf("a (1).txt"), blocked.taken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `files that already have the pattern's names are left alone`() {
|
||||
val entries = listOf(file("Ice (1).jpg"), file("Ice (2).jpg"))
|
||||
val existing = listOf("Ice (1).jpg", "Ice (2).jpg")
|
||||
|
||||
assertEquals(BatchRenamePlan.NothingToDo, BatchRename.plan("Ice", 1, entries, existing))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file that already has its target name is counted and skipped, not renamed to itself`() {
|
||||
val entries = listOf(file("Ice (1).jpg"), file("new.jpg"))
|
||||
val existing = listOf("Ice (1).jpg", "new.jpg")
|
||||
|
||||
val ready = BatchRename.plan("Ice", 1, entries, existing) as BatchRenamePlan.Ready
|
||||
|
||||
assertEquals("only the file that has to move does", listOf("Ice (2).jpg"), ready.moves.map { it.to })
|
||||
assertEquals(1, ready.unchanged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start below one is treated as one, because there is no zeroth copy`() {
|
||||
val entries = listOf(file("a.jpg"))
|
||||
val ready = BatchRename.plan("Ice", 0, entries, listOf("a.jpg")) as BatchRenamePlan.Ready
|
||||
|
||||
assertEquals(listOf("Ice (1).jpg"), ready.moves.map { it.to })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no extension gains none`() {
|
||||
val entries = listOf(file(".hidden"), file("README"))
|
||||
val ready = BatchRename.plan("Ice", 1, entries, listOf(".hidden", "README")) as BatchRenamePlan.Ready
|
||||
|
||||
// A leading dot marks a hidden file rather than an extension, so the whole name is the stem.
|
||||
assertEquals(listOf("Ice (1)", "Ice (2)"), ready.moves.map { it.to })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a base has to be a name, not a path`() {
|
||||
// The rule itself lives with the other name helpers; what matters here is that a batch
|
||||
// cannot be started with something that would move a file rather than rename it.
|
||||
assertTrue(BatchRename.isUsableBase("Holiday 2026"))
|
||||
assertFalse(BatchRename.isUsableBase("notes/final"))
|
||||
assertFalse(BatchRename.isUsableBase(""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.softwareapp_hb.filemanager.core
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* What counts as a name. Small, but it is the difference between renaming a file and moving it:
|
||||
* `child("notes/final")` is a path into a subfolder, so a dialog that accepted one would do
|
||||
* something other than what it offered.
|
||||
*/
|
||||
class FileNamesTest {
|
||||
|
||||
@Test
|
||||
fun `an ordinary name is usable`() {
|
||||
assertTrue(isUsableName("Holiday 2026.jpg"))
|
||||
assertTrue(isUsableName("Ice (2024)"))
|
||||
assertTrue(isUsableName("README"))
|
||||
assertTrue(isUsableName(".nomedia"))
|
||||
assertTrue(isUsableName("report.final v2.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a separator is not part of a name`() {
|
||||
assertFalse(isUsableName("notes/final.txt"))
|
||||
assertFalse(isUsableName("notes\\final.txt"))
|
||||
assertFalse(isUsableName("/absolute"))
|
||||
assertFalse(isUsableName("a/b/c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a name has to say something`() {
|
||||
assertFalse(isUsableName(""))
|
||||
assertFalse(isUsableName(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null byte cannot be in a name`() {
|
||||
assertFalse(isUsableName("a\u0000b"))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.net.Uri
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.core.BatchRename
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.FileCategory
|
||||
import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
@@ -1283,6 +1284,102 @@ class BrowserViewModelTest {
|
||||
assertNull(model.state.value.undo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch rename numbers the files in the order they are listed`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
val entries = model.state.value.entries.filter { !it.isDirectory }
|
||||
|
||||
model.renameAll(entries, "photo", 1)
|
||||
|
||||
assertTrue(storage.contains(downloads.child("photo (1).txt")))
|
||||
assertTrue(storage.contains(downloads.child("photo (2).txt")))
|
||||
assertFalse(storage.contains(downloads.child("a.txt")))
|
||||
// A rename is a rename: the contents move with the name, they are not copied to a new file.
|
||||
assertEquals("first", storage.contentOf(downloads.child("photo (1).txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch rename continues a series the folder already has`() {
|
||||
// The complaint the feature came from: the first batch owns 1 and 2, and a second batch has
|
||||
// to be able to ask for 3 and 4.
|
||||
storage.addFile(downloads.child("photo (1).txt"), "old")
|
||||
storage.addFile(downloads.child("photo (2).txt"), "older")
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
|
||||
val names = model.state.value.entries.map { it.name }
|
||||
val start = BatchRename.nextNumber("photo", names)
|
||||
val selected = model.state.value.entries.filter { it.name == "a.txt" || it.name == "b.txt" }
|
||||
model.renameAll(selected, "photo", start)
|
||||
|
||||
assertEquals(3, start)
|
||||
assertTrue(storage.contains(downloads.child("photo (3).txt")))
|
||||
assertTrue(storage.contains(downloads.child("photo (4).txt")))
|
||||
// The files that were already in the series are untouched.
|
||||
assertEquals("old", storage.contentOf(downloads.child("photo (1).txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a batch rename is a single undo step`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
val entries = model.state.value.entries.filter { !it.isDirectory }
|
||||
|
||||
model.renameAll(entries, "photo", 1)
|
||||
|
||||
val undo = model.state.value.undo
|
||||
assertTrue(undo is Undo.Relocate)
|
||||
assertEquals("both renames, taken back together", 2, (undo as Undo.Relocate).moves.size)
|
||||
|
||||
model.undo()
|
||||
|
||||
assertTrue(storage.contains(downloads.child("a.txt")))
|
||||
assertTrue(storage.contains(downloads.child("b.txt")))
|
||||
assertFalse(storage.contains(downloads.child("photo (1).txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch rename refuses a batch whose names are already taken`() {
|
||||
storage.addFile(downloads.child("photo (1).txt"), "in the way")
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
val entries = model.state.value.entries.filter { it.name == "a.txt" || it.name == "b.txt" }
|
||||
|
||||
model.renameAll(entries, "photo", 1)
|
||||
|
||||
assertTrue("nothing moved at all", storage.contains(downloads.child("a.txt")))
|
||||
assertTrue(storage.contains(downloads.child("b.txt")))
|
||||
assertEquals(R.string.browser_rename_many_taken, model.state.value.message?.textRes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a filename cannot be a path`() {
|
||||
// `notes/final.txt` resolves to a path into a subfolder, so a rename that accepted it would
|
||||
// move the file rather than rename it. The subfolder exists here, which is the case that
|
||||
// would have silently succeeded.
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
storage.addDirectory(downloads.child("notes"))
|
||||
|
||||
model.rename(downloads.child("a.txt"), "notes/final.txt")
|
||||
|
||||
assertTrue("the file stays where it was", storage.contains(downloads.child("a.txt")))
|
||||
assertFalse(storage.contains(downloads.child("notes").child("final.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a batch rename cannot be given a path either`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
val entries = model.state.value.entries.filter { !it.isDirectory }
|
||||
|
||||
model.renameAll(entries, "notes/final", 1)
|
||||
|
||||
assertTrue(storage.contains(downloads.child("a.txt")))
|
||||
assertTrue(storage.contains(downloads.child("b.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undoing a rename puts the old name back`() {
|
||||
val model = viewModel()
|
||||
|
||||
@@ -307,7 +307,7 @@ requested feature in the market leader, and the permission line is a claim it ca
|
||||
|---|---|---|
|
||||
| Automatic category libraries (Images / Audio / Videos / Documents / Downloads / New files / Recent) | **No** | The most-praised organisational feature in the reviews, and the one thing a brand-new user sees before they understand folders. We open on a volume list instead. This is the largest honest gap |
|
||||
| Duplicate finder | **No** | Explicitly requested, with an offer to pay. Pure local computation over a listing we can already do |
|
||||
| Batch rename, including continuing an existing numbering sequence | **No** | We have single-item rename only; "Ice (3), Ice (4)" is a stated wish from the top complainant |
|
||||
| Batch rename, including continuing an existing numbering sequence | **No** | **Closed in M14.** Several files are renamed to one name with a number after each, and the numbering continues the folder's own series, so the "Ice (3), Ice (4)" wish from the top complainant is what the dialog now defaults to |
|
||||
| Bookmarks | **No** | Their action list includes it; we have none |
|
||||
| "Copy again" / "Return to source folder" after a paste | **No** | Shipped in their 3.6.8 and cheap for us — the post-paste moment is when a user most needs a way back |
|
||||
| New-file dot after an operation | **No** | Their 3.6.5. Cheap, and it answers "what did that 4 GB copy just do?" |
|
||||
@@ -346,13 +346,15 @@ latency, so this needs to be pinned down rather than assumed:
|
||||
|
||||
## What I would do with this
|
||||
|
||||
In the ROADMAP's own vocabulary (💡 = candidate, proposed, not scheduled):
|
||||
In the ROADMAP's own vocabulary (💡 = candidate, proposed, not scheduled; ✅ = since shipped):
|
||||
|
||||
1. 💡 **Category libraries (Images / Audio / Videos / Documents / Downloads · New files · Recent).**
|
||||
The largest honest gap: it is the market leader's most-praised organisation feature, it needs no
|
||||
permission, and it is the first thing a new user sees.
|
||||
2. 💡 **Duplicate finder.** Asked for by name, by a user offering to pay.
|
||||
3. 💡 **Batch rename with sequence continuation** — including the "continue from Ice (2)" case.
|
||||
1. ✅ **Category libraries (Images / Audio / Videos / Documents · New files · Recent)** — shipped as
|
||||
**M11**, with Downloads and New files left out on purpose. It was the largest honest gap: the
|
||||
market leader's most-praised organisation feature, needing no permission, and the first thing a
|
||||
new user sees.
|
||||
2. ✅ **Duplicate finder** — shipped as **M12**. Asked for by name, by a user offering to pay.
|
||||
3. ✅ **Batch rename with sequence continuation** — shipped as **M14**, including the "continue from
|
||||
Ice (2)" case that the top complainant names.
|
||||
4. 💡 **Bookmarks, plus "Return to source folder" and "Copy again" after a paste.** Cheap, local,
|
||||
and the post-paste moment is the one the competitor's changelog calls out.
|
||||
5. 💡 **Highlight files changed by an operation.** Their 3.6.5; answers an obvious question.
|
||||
|
||||
@@ -192,6 +192,46 @@ through the prompt, because the prompt was already covered in the M7 session.
|
||||
the AVDs have no `/dev/kvm` here, and the two-pane root list is where the libraries now appear
|
||||
first.
|
||||
|
||||
## The batch rename session (M14)
|
||||
|
||||
Driven on the Xiaomi phone against a purpose-made folder — `/sdcard/AAABatchRename`, holding
|
||||
`alpha.txt`, `beta.txt`, `gamma.txt` and an existing series `series (1).txt` / `series (2).txt` —
|
||||
because the one thing worth proving here is the **continuation**, and that needs a folder that
|
||||
already holds a series. Every step went through the UI with `adb input`, and each claim below was
|
||||
read back off the device rather than off the screen.
|
||||
|
||||
- **The dialog continues the series on its own.** Three files selected (long-press, then a tap each —
|
||||
the M4 tap-to-toggles behaviour), Rename from the action bar, `series` typed into the name and the
|
||||
**Start at** field moved from 1 to **3** by itself, with the preview reading *Will be renamed to
|
||||
series (3).txt, series (4).txt, series (5).txt*. Nothing but the names in the folder was consulted,
|
||||
and those are the listing on screen, so the dialog and the view model cannot disagree about them.
|
||||
- **The work is exactly what the dialog promised.** `ls` afterwards showed `series (1) … (5)`, the
|
||||
two originals untouched, and `cat` showed `series (3).txt` holding `alpha` and `series (4).txt`
|
||||
holding `beta` — the renames *moved* the files rather than copying them.
|
||||
- **One undo step, through the snackbar.** A second batch was renamed to `clip (1..3)`, the snackbar
|
||||
read *Renamed 3 files* with **Undo**, and a single tap put all three names back, contents intact
|
||||
through the round trip (`series (5).txt` still held `gamma`).
|
||||
- **Both refusals are visible before anything can be pressed.** With `series` typed and the start
|
||||
forced back to 1 over a folder holding `series (1)`/`series (2)`, the dialog read *series (1).txt is
|
||||
already in this folder* — the file in the way, named — and the only `enabled="false"` node in the
|
||||
whole dump was the Rename button. Renaming two files to the names they already hold read *Every
|
||||
selected file already has that name* instead, which is the honest version of "nothing to do", and a
|
||||
name containing `/` read *A name cannot contain a slash*, button disabled as well.
|
||||
- **The test folder was removed afterwards.** It was created for this pass, in the phone's own
|
||||
storage, and deleted through `adb` at the end: the M10 rule about not leaving artefacts on a device
|
||||
that is not ours applies to a scratch folder as much as to screenshots.
|
||||
|
||||
Two things this session added to the tooling notes:
|
||||
|
||||
- **`adb input text` does not deliver a `/` reliably.** `'a/b'` arrived in the field mangled (`a!c`,
|
||||
after the trailing character had to be deleted), so the slash case was sent as `input keyevent 76`
|
||||
— worth knowing before concluding that a field filters separators: here the *field* accepted it and
|
||||
the dialog is what refused it.
|
||||
- **The dialog lifts once the keyboard appears.** Its fields sit ~200 px higher afterwards, so a
|
||||
second field has to be aimed at the post-keyboard layout. Two dumps were needed to tell the two
|
||||
positions apart, which is also why the numbers quoted above come from a dump rather than from
|
||||
memory.
|
||||
|
||||
## Not verified, and why
|
||||
|
||||
- **Long-press multi-select via adb: it works, but not every time.** `input swipe x y x y 900`
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
File Manager is built against a fixed milestone plan (**M0–M10**). This file is the
|
||||
File Manager is built against a fixed milestone plan (**M0–M14**). This file is the
|
||||
single source of truth for what is done and what remains.
|
||||
|
||||
Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (proposed, not scheduled)
|
||||
@@ -23,7 +23,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
|
||||
| M11 | Library views | ✅ | A **Libraries** section on the root screen — Images, Videos, Audio, Documents, Archives, Recent — each row carrying how many files the index knows of that kind, each opening an ordinary listing (thumbnails, sort, multi-select, details, the existing viewers) answered from the index instead of from a directory. `core/FileCategory` groups `FileKind`s and is asserted to leave `DIRECTORY`, `APK` and `OTHER` out deliberately; `SearchIndex` gained `byKind`/`recent`/`kindCounts`, and SQLite schema **v3** adds the derived `kind` column — the migration rebuilds the table *and* drops the volume stamps, because a stamp whose rows have gone would make the next search trust an empty index. A library obeys the indexed search's own two rules: the answer comes first with the walk behind it, and every row is confirmed against the filesystem before it is shown. Not included, and recorded in the CHANGELOG as decisions rather than omissions: Downloads and New files, and restoring a library across process death. **308 JVM tests** (19 added here) |
|
||||
| M12 | Duplicate finder | ✅ | ***Find duplicates*** **in the overflow.** Three passes, cheapest first: **size** (the index's `duplicateCandidates` query — one query and no reading at all), **the first 64 KB** of every candidate (a filter that may only rule files *out*), and the **whole file** to confirm (`domain/DuplicateFinder`, SHA-256, cancellable between files and between read buffers). Groups are ordered by reclaimable space; each header states the copies and what they cost; every row carries its folder, because identical names with no paths are unactionable; and **nothing is preselected** — the delete is the app's ordinary one, through the trash and the undo snackbar. A file that cannot be read is counted and reported rather than skipped, and the empty state says how many files were compared — or that the index has never run and there was nothing to compare. **323 JVM tests** (15 added). Verified on the Xiaomi phone against four genuine groups in its own storage; the delete was deliberately not pressed on a device that is not ours — see [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md) |
|
||||
| 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) |
|
||||
## Key decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
@@ -47,7 +47,8 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
|
||||
|
||||
A survey of the market leader (its features, and what its users praise and complain about) is in
|
||||
[COMPETITIVE_ANALYSIS](COMPETITIVE_ANALYSIS.md). Its gaps that cost **no** new permission are
|
||||
listed there as 💡 candidates. **Category libraries were the first of them and are now M11, and the
|
||||
duplicate finder the second (M12)**; the rest — batch rename with sequence continuation, bookmarks,
|
||||
and the post-paste "copy again" / "return to source folder" — are proposals, not scheduled work, and
|
||||
adding one is a roadmap decision rather than a side effect of reading that document.
|
||||
listed there as 💡 candidates. **Category libraries were the first of them and are now M11, the
|
||||
duplicate finder the second (M12) and batch rename with sequence continuation the third (M14)**; the
|
||||
rest — bookmarks, and the post-paste "copy again" / "return to source folder" — are proposals, not
|
||||
scheduled work, and adding one is a roadmap decision rather than a side effect of reading that
|
||||
document.
|
||||
|
||||
@@ -122,6 +122,7 @@ WORKS WITH YOUR OTHER APPS
|
||||
|
||||
MANAGE YOUR FILES
|
||||
• Copy, move, rename and delete, one item or many at once
|
||||
• Rename a whole batch at once, numbered in a series that carries on where the last one stopped
|
||||
• Create folders and edit text files in the built-in editor
|
||||
• See the details of anything: size, type, location, modified date
|
||||
• Open a file in any app, or share it
|
||||
|
||||
Reference in New Issue
Block a user