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:
2026-10-17 02:06:19 +02:00
parent 7985d33869
commit 1220aa6679
16 changed files with 829 additions and 22 deletions
@@ -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))
}
/*
* 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 = onRename) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.browser_action_rename))
}
IconButton(onClick = onDetails) {
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.browser_action_details))
}
+22
View File
@@ -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()