M1: pure-Kotlin core engine (paths, classification, sorting, search, transfers) + 44 JVM tests

This commit is contained in:
2026-10-14 15:05:14 +02:00
parent adfd95fc38
commit 6d4c2c3f87
19 changed files with 1038 additions and 3 deletions
@@ -0,0 +1,29 @@
package de.softwareapp_hb.filemanager.core
/**
* One row in a directory listing.
*
* This is the neutral shape both storage backends produce, so sorting, searching and
* the UI never need to know whether the entry came from `java.io.File` or from a
* `content://` document.
*/
data class FileEntry(
val path: StoragePath,
val isDirectory: Boolean,
val sizeBytes: Long,
val lastModifiedMillis: Long,
) {
val name: String get() = path.name
val kind: FileKind get() = FileKind.of(name, isDirectory)
val isHidden: Boolean get() = isHiddenName(name)
}
/**
* Whether a name should be treated as hidden.
*
* Android's own convention is the leading dot (`.nomedia`, `.thumbnails`), which is
* also what the user sees on desktop file managers.
*/
fun isHiddenName(name: String): Boolean = name.startsWith('.') && name.length > 1
@@ -0,0 +1,73 @@
package de.softwareapp_hb.filemanager.core
/**
* The coarse category a file belongs to, derived from its name.
*
* Used for the icon shown in the list, for "sort by type" and for grouping the
* library-wide views (images, videos, downloads…).
*/
enum class FileKind {
DIRECTORY,
IMAGE,
VIDEO,
AUDIO,
DOCUMENT,
ARCHIVE,
APK,
TEXT,
OTHER,
;
companion object {
private val IMAGE_EXTENSIONS = setOf(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif", "avif",
"tif", "tiff", "svg", "ico", "raw", "dng",
)
private val VIDEO_EXTENSIONS = setOf(
"mp4", "mkv", "webm", "avi", "mov", "wmv", "flv", "m4v", "3gp",
"mpeg", "mpg", "ts", "m2ts",
)
private val AUDIO_EXTENSIONS = setOf(
"mp3", "aac", "m4a", "ogg", "oga", "opus", "wav", "flac", "amr",
"mid", "midi", "wma",
)
private val DOCUMENT_EXTENSIONS = setOf(
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods",
"odp", "rtf", "csv", "epub", "mobi",
)
private val ARCHIVE_EXTENSIONS = setOf(
"zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar", "jar", "cbz",
)
private val TEXT_EXTENSIONS = setOf(
"txt", "md", "log", "json", "xml", "yaml", "yml", "ini", "conf",
"html", "htm", "kt", "kts", "java", "py", "js", "ts", "sh", "c",
"cpp", "h", "css", "srt", "vtt",
)
/** Classify a name. A directory always wins over its extension. */
fun of(name: String, isDirectory: Boolean): FileKind {
if (isDirectory) return DIRECTORY
return when (extensionOf(name)) {
in IMAGE_EXTENSIONS -> IMAGE
in VIDEO_EXTENSIONS -> VIDEO
in AUDIO_EXTENSIONS -> AUDIO
in DOCUMENT_EXTENSIONS -> DOCUMENT
in ARCHIVE_EXTENSIONS -> ARCHIVE
in TEXT_EXTENSIONS -> TEXT
"apk", "apks", "xapk" -> APK
else -> OTHER
}
}
/**
* The lower-cased extension without its dot, or `""` when there is none.
*
* A leading dot marks a hidden file (`.bashrc`) rather than an extension of
* `bashrc`, so it is deliberately not treated as an extension.
*/
fun extensionOf(name: String): String {
val dot = name.lastIndexOf('.')
return if (dot <= 0 || dot == name.length - 1) "" else name.substring(dot + 1).lowercase()
}
}
}
@@ -0,0 +1,27 @@
package de.softwareapp_hb.filemanager.core
import java.util.Locale
/**
* Human-readable byte sizes for the file list ("1.5 MB").
*
* Binary units (1 KB = 1024 B) are used because that is what every Android file
* manager and the system Settings app show, and the labels are deliberately left
* unlocalised — they are symbols, not words.
*/
object FileSize {
private val UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
fun format(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
var value = bytes.toDouble()
var unit = 0
while (value >= 1024.0 && unit < UNITS.lastIndex) {
value /= 1024.0
unit++
}
return String.format(Locale.US, "%.1f %s", value, UNITS[unit])
}
}
@@ -0,0 +1,93 @@
package de.softwareapp_hb.filemanager.core
/**
* Best-effort MIME lookup for the "open with" intent.
*
* A file manager has to hand a concrete MIME type to `ACTION_VIEW`; guessing from the
* extension covers the formats users actually open, and everything else falls back to
* the generic binary type so the system chooser still appears.
*/
object MimeTypes {
const val BINARY = "application/octet-stream"
const val DIRECTORY = "vnd.android.document/directory"
private val BY_EXTENSION = mapOf(
// images
"jpg" to "image/jpeg",
"jpeg" to "image/jpeg",
"png" to "image/png",
"gif" to "image/gif",
"webp" to "image/webp",
"bmp" to "image/bmp",
"heic" to "image/heic",
"heif" to "image/heif",
"avif" to "image/avif",
"tif" to "image/tiff",
"tiff" to "image/tiff",
"svg" to "image/svg+xml",
// video
"mp4" to "video/mp4",
"m4v" to "video/mp4",
"mkv" to "video/x-matroska",
"webm" to "video/webm",
"avi" to "video/x-msvideo",
"mov" to "video/quicktime",
"3gp" to "video/3gpp",
"ts" to "video/mp2t",
// audio
"mp3" to "audio/mpeg",
"m4a" to "audio/mp4",
"aac" to "audio/aac",
"ogg" to "audio/ogg",
"oga" to "audio/ogg",
"opus" to "audio/opus",
"wav" to "audio/x-wav",
"flac" to "audio/flac",
"amr" to "audio/amr",
"mid" to "audio/midi",
"midi" to "audio/midi",
// documents
"pdf" to "application/pdf",
"doc" to "application/msword",
"docx" to "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" to "application/vnd.ms-excel",
"xlsx" to "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt" to "application/vnd.ms-powerpoint",
"pptx" to "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"epub" to "application/epub+zip",
"csv" to "text/csv",
// archives
"zip" to "application/zip",
"tar" to "application/x-tar",
"gz" to "application/gzip",
"tgz" to "application/gzip",
"bz2" to "application/x-bzip2",
"xz" to "application/x-xz",
"7z" to "application/x-7z-compressed",
"rar" to "application/vnd.rar",
"jar" to "application/java-archive",
"apk" to "application/vnd.android.package-archive",
// text
"txt" to "text/plain",
"md" to "text/markdown",
"log" to "text/plain",
"json" to "application/json",
"xml" to "text/xml",
"yaml" to "text/yaml",
"yml" to "text/yaml",
"html" to "text/html",
"htm" to "text/html",
"css" to "text/css",
"js" to "text/javascript",
"srt" to "application/x-subrip",
"vtt" to "text/vtt",
)
/** MIME type for a file name, or [BINARY] when the extension is unknown. */
fun forName(name: String): String = BY_EXTENSION[FileKind.extensionOf(name)] ?: BINARY
/** MIME type for an entry — directories get the SAF directory type. */
fun forEntry(entry: FileEntry): String =
if (entry.isDirectory) DIRECTORY else forName(entry.name)
}
@@ -0,0 +1,30 @@
package de.softwareapp_hb.filemanager.core
/**
* Picks a name that is not already in use, in the familiar "name (1)" style.
*
* Used by "New folder", and by copy/move when the user chooses to keep both copies.
*/
object NameAllocator {
/**
* Return [desired] if it is free, otherwise the first free `base (n) ext`.
*
* The extension is preserved: `photo.jpg` becomes `photo (1).jpg`, never
* `photo.jpg (1)`.
*/
fun uniqueName(desired: String, isTaken: (String) -> Boolean): String {
if (!isTaken(desired)) return desired
val dot = desired.lastIndexOf('.')
val base = if (dot <= 0) desired else desired.substring(0, dot)
val extension = if (dot <= 0) "" else desired.substring(dot)
var n = 1
while (true) {
val candidate = "$base ($n)$extension"
if (!isTaken(candidate)) return candidate
n++
}
}
}
@@ -0,0 +1,63 @@
package de.softwareapp_hb.filemanager.core
/**
* The in-folder search matcher.
*
* A plain query is a case-insensitive substring test, which is what a user typing
* "holiday" expects. If the query contains `*` or `?` it is treated as a glob, so
* `IMG_*.png` works too. An empty query matches everything.
*/
object NameMatcher {
fun matches(name: String, query: String): Boolean {
val trimmed = query.trim()
if (trimmed.isEmpty()) return true
return if (trimmed.any { it == '*' || it == '?' }) {
glob(trimmed, name)
} else {
name.contains(trimmed, ignoreCase = true)
}
}
/**
* Iterative `*` / `?` wildcard match, case-insensitive.
*
* The linear two-pointer form is used rather than a recursive one so a pathological
* pattern cannot blow the stack.
*/
fun glob(pattern: String, text: String): Boolean {
val p = pattern.lowercase()
val t = text.lowercase()
var pi = 0
var ti = 0
var star = -1
var starMatch = 0
while (ti < t.length) {
when {
pi < p.length && (p[pi] == '?' || p[pi] == t[ti]) -> {
pi++
ti++
}
pi < p.length && p[pi] == '*' -> {
star = pi
starMatch = ti
pi++
}
star != -1 -> {
pi = star + 1
starMatch++
ti = starMatch
}
else -> return false
}
}
while (pi < p.length && p[pi] == '*') pi++
return pi == p.length
}
}
@@ -0,0 +1,82 @@
package de.softwareapp_hb.filemanager.core
/** What a directory listing is ordered by. */
enum class SortKey { NAME, SIZE, DATE, KIND }
enum class SortDirection { ASCENDING, DESCENDING }
/**
* A complete, serialisable description of the ordering of a directory listing.
*
* Two rules are baked in because every desktop-class file manager agrees on them:
* **directories always come first** (in both directions — reversing the sort reorders
* the contents, it does not interleave folders among files), and ties are broken by
* name so the order is stable.
*/
data class SortSpec(
val key: SortKey = SortKey.NAME,
val direction: SortDirection = SortDirection.ASCENDING,
) {
/** Sort [entries] without mutating the input. */
fun sort(entries: List<FileEntry>): List<FileEntry> = entries.sortedWith(comparator)
val comparator: Comparator<FileEntry>
get() = Comparator { a, b -> compare(a, b) }
private fun compare(a: FileEntry, b: FileEntry): Int {
if (a.isDirectory != b.isDirectory) return if (a.isDirectory) -1 else 1
val base = when (key) {
SortKey.NAME -> NaturalOrder.compare(a.name, b.name)
SortKey.SIZE -> a.sizeBytes.compareTo(b.sizeBytes)
SortKey.DATE -> a.lastModifiedMillis.compareTo(b.lastModifiedMillis)
SortKey.KIND -> a.kind.ordinal.compareTo(b.kind.ordinal)
}
val ordered = if (direction == SortDirection.ASCENDING) base else -base
// Stable, predictable tie-break: name ascending.
return if (ordered != 0) ordered else NaturalOrder.compare(a.name, b.name)
}
}
/**
* "Natural" (human) string ordering, so `file2` sorts before `file10`.
*
* A plain lexicographic comparison would put `file10` first, which is the single most
* common complaint about simple file lists. Runs of digits are compared by numeric
* value; everything else is compared case-insensitively.
*/
object NaturalOrder : Comparator<String> {
override fun compare(a: String, b: String): Int {
var i = 0
var j = 0
while (i < a.length && j < b.length) {
val ca = a[i]
val cb = b[j]
if (ca.isDigit() && cb.isDigit()) {
var endA = i
while (endA < a.length && a[endA].isDigit()) endA++
var endB = j
while (endB < b.length && b[endB].isDigit()) endB++
val digitsA = a.substring(i, endA).trimStart('0')
val digitsB = b.substring(j, endB).trimStart('0')
val byLength = digitsA.length - digitsB.length
if (byLength != 0) return byLength
val byValue = digitsA.compareTo(digitsB)
if (byValue != 0) return byValue
i = endA
j = endB
} else {
val byChar = ca.lowercaseChar().compareTo(cb.lowercaseChar())
if (byChar != 0) return byChar
i++
j++
}
}
return (a.length - i) - (b.length - j)
}
}
@@ -0,0 +1,89 @@
package de.softwareapp_hb.filemanager.core
/**
* A normalized, absolute, **backend-agnostic** storage path.
*
* The two storage backends ([de.softwareapp_hb.filemanager.data.AllFilesSource] and
* `SafSource`) speak different native vocabularies — `java.io.File` and
* `content://` document URIs — so the rest of the app navigates this single value
* type instead. It uses `/` as the separator on every device and always starts at a
* root, which is what makes the browsing code independent of where a folder actually
* lives.
*
* Normalization rules:
* - a leading `/` is added if missing (paths are always absolute);
* - empty segments and `.` are dropped, repeated `/` collapse;
* - `..` pops one segment, and is ignored at the root (so it can never escape);
* - the root itself is `"/"` and has no [name].
*
* Instances are immutable and compare by value.
*/
class StoragePath private constructor(val value: String) {
/** The individual segments below the root; empty for the root itself. */
val segments: List<String> =
if (value == ROOT_VALUE) emptyList() else value.substring(1).split(SINGLE_SEPARATOR)
val isRoot: Boolean get() = value == ROOT_VALUE
/** The last segment, or `"/"` for the root. */
val name: String get() = segments.lastOrNull() ?: ROOT_VALUE
/** The containing path, or `null` for the root. */
val parent: StoragePath?
get() = when {
isRoot -> null
segments.size == 1 -> Root
else -> of(ROOT_VALUE + segments.dropLast(1).joinToString(SINGLE_SEPARATOR))
}
/** Resolve [relative] against this path (absolute input replaces this path). */
fun resolve(relative: String): StoragePath {
if (relative.isEmpty()) return this
val combined = if (relative.startsWith(ROOT_VALUE)) relative else "$value/$relative"
return of(combined)
}
/** A child of this path — shorthand for [resolve] with a bare name. */
fun child(name: String): StoragePath = resolve(name)
/** `true` when this path is strictly above [other]. */
fun isAncestorOf(other: StoragePath): Boolean =
other.segments.size > segments.size &&
other.segments.subList(0, segments.size) == segments
/** `true` when this path is strictly below [other]. */
fun isDescendantOf(other: StoragePath): Boolean = other.isAncestorOf(this)
override fun toString(): String = value
override fun equals(other: Any?): Boolean = other is StoragePath && other.value == value
override fun hashCode(): Int = value.hashCode()
companion object {
private const val ROOT_VALUE = "/"
private const val SINGLE_SEPARATOR = "/"
/** The single root every absolute path descends from. */
val Root: StoragePath = StoragePath(ROOT_VALUE)
/** Normalize [raw] into a canonical [StoragePath]. */
fun of(raw: String): StoragePath {
val normalized = normalize(raw)
return if (normalized == ROOT_VALUE) Root else StoragePath(normalized)
}
private fun normalize(raw: String): String {
val stack = ArrayDeque<String>()
for (part in raw.split('/')) {
when (part) {
"", "." -> Unit
".." -> if (stack.isNotEmpty()) stack.removeLast()
else -> stack.addLast(part)
}
}
return if (stack.isEmpty()) ROOT_VALUE else stack.joinToString("/", prefix = "/")
}
}
}
@@ -0,0 +1,105 @@
package de.softwareapp_hb.filemanager.core
/** What to do when an item being copied or moved already exists in the destination. */
enum class ConflictPolicy {
/** Prompt the user, item by item. */
ASK,
/** Overwrite the existing item. */
REPLACE,
/** Keep both, renaming the incoming one to `name (1)`. */
KEEP_BOTH,
/** Leave the existing item alone and copy nothing. */
SKIP,
}
/** The resolved decision for a single source item. */
enum class TransferAction { COPY, REPLACE, KEEP_BOTH, SKIP, ASK }
/** A file or folder being copied or moved. */
data class TransferSource(val path: StoragePath, val isDirectory: Boolean) {
val name: String get() = path.name
}
/** One resolved item of a transfer plan. */
data class TransferItem(
val source: TransferSource,
val target: StoragePath,
val action: TransferAction,
)
/**
* Turns "copy/paste these items into that folder" into a per-item decision, before a
* single byte is written.
*
* Doing this as pure logic keeps the awkward cases — a name colliding with another
* item *in the same batch*, "keep both" renaming, and a folder being moved into
* itself — testable without touching a real filesystem.
*/
object TransferPlanner {
/**
* `true` when moving [source] into [destination] is impossible because the
* destination lies inside the source (`/a` into `/a/b`), which would make the
* folder its own child.
*/
fun moveIntoSelf(source: TransferSource, destination: StoragePath): Boolean =
source.isDirectory &&
(destination == source.path || destination.isDescendantOf(source.path))
/**
* Resolve every item against [destination].
*
* [exists] answers "is this target path already taken?" for the real backend.
* Names claimed earlier in the *same* batch are tracked separately so two files
* called `photo.jpg` copied together cannot both land on `photo.jpg`.
*/
fun plan(
sources: List<TransferSource>,
destination: StoragePath,
policy: ConflictPolicy,
exists: (StoragePath) -> Boolean,
): List<TransferItem> {
val reserved = HashSet<String>()
val items = ArrayList<TransferItem>(sources.size)
for (source in sources) {
val target = destination.child(source.name)
if (moveIntoSelf(source, destination)) {
items += TransferItem(source, target, TransferAction.SKIP)
continue
}
val taken = reserved.contains(source.name) || exists(target)
if (!taken) {
reserved += source.name
items += TransferItem(source, target, TransferAction.COPY)
continue
}
when (policy) {
ConflictPolicy.REPLACE -> {
reserved += source.name
items += TransferItem(source, target, TransferAction.REPLACE)
}
ConflictPolicy.SKIP -> items += TransferItem(source, target, TransferAction.SKIP)
ConflictPolicy.ASK -> items += TransferItem(source, target, TransferAction.ASK)
ConflictPolicy.KEEP_BOTH -> {
val unique = NameAllocator.uniqueName(source.name) { candidate ->
reserved.contains(candidate) || exists(destination.child(candidate))
}
reserved += unique
items += TransferItem(source, destination.child(unique), TransferAction.KEEP_BOTH)
}
}
}
return items
}
}
@@ -0,0 +1,53 @@
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
class FileEntryTest {
@Test
fun `derives name and kind from the path`() {
val entry = entry("/a/photo.jpg", isDirectory = false)
assertEquals("photo.jpg", entry.name)
assertEquals(FileKind.IMAGE, entry.kind)
}
@Test
fun `hidden follows the leading-dot convention`() {
assertTrue(isHiddenName(".nomedia"))
assertFalse(isHiddenName("nomedia"))
assertFalse(isHiddenName("."))
assertTrue(entry("/a/.thumbnails", isDirectory = true).isHidden)
}
private fun entry(path: String, isDirectory: Boolean) =
FileEntry(StoragePath.of(path), isDirectory, sizeBytes = 0, lastModifiedMillis = 0)
}
class MimeTypesTest {
@Test
fun `maps known extensions`() {
assertEquals("image/jpeg", MimeTypes.forName("holiday.jpg"))
assertEquals("application/pdf", MimeTypes.forName("report.PDF"))
assertEquals("application/vnd.android.package-archive", MimeTypes.forName("app.apk"))
}
@Test
fun `falls back to the binary type`() {
assertEquals(MimeTypes.BINARY, MimeTypes.forName("mystery.bin"))
assertEquals(MimeTypes.BINARY, MimeTypes.forName("README"))
}
@Test
fun `directories report the directory type`() {
val dir = FileEntry(StoragePath.of("/a/b"), isDirectory = true, sizeBytes = 0, lastModifiedMillis = 0)
assertEquals(MimeTypes.DIRECTORY, MimeTypes.forEntry(dir))
assertEquals(
"image/png",
MimeTypes.forEntry(dir.copy(path = StoragePath.of("/a/b.png"), isDirectory = false)),
)
}
}
@@ -0,0 +1,46 @@
package de.softwareapp_hb.filemanager.core
import org.junit.Assert.assertEquals
import org.junit.Test
class FileKindTest {
@Test
fun `a directory wins over its extension`() {
assertEquals(FileKind.DIRECTORY, FileKind.of("photo.jpg", isDirectory = true))
}
@Test
fun `classifies common extensions case-insensitively`() {
assertEquals(FileKind.IMAGE, FileKind.of("Photo.JPG", isDirectory = false))
assertEquals(FileKind.VIDEO, FileKind.of("clip.mp4", isDirectory = false))
assertEquals(FileKind.AUDIO, FileKind.of("song.flac", isDirectory = false))
assertEquals(FileKind.DOCUMENT, FileKind.of("report.pdf", isDirectory = false))
assertEquals(FileKind.TEXT, FileKind.of("notes.md", isDirectory = false))
}
@Test
fun `only the last extension counts`() {
assertEquals(FileKind.ARCHIVE, FileKind.of("backup.tar.gz", isDirectory = false))
}
@Test
fun `apk is recognised separately from archives`() {
assertEquals(FileKind.APK, FileKind.of("app.apk", isDirectory = false))
}
@Test
fun `unknown and hidden names are other`() {
assertEquals(FileKind.OTHER, FileKind.of("README", isDirectory = false))
assertEquals(FileKind.OTHER, FileKind.of(".bashrc", isDirectory = false))
}
@Test
fun `extensionOf ignores dotfiles and trailing dots`() {
assertEquals("", FileKind.extensionOf(".bashrc"))
assertEquals("", FileKind.extensionOf("README"))
assertEquals("", FileKind.extensionOf("name."))
assertEquals("txt", FileKind.extensionOf("a.TXT"))
assertEquals("gz", FileKind.extensionOf("a.tar.gz"))
}
}
@@ -0,0 +1,23 @@
package de.softwareapp_hb.filemanager.core
import org.junit.Assert.assertEquals
import org.junit.Test
class FileSizeTest {
@Test
fun `bytes are shown without decimals`() {
assertEquals("0 B", FileSize.format(0))
assertEquals("1 B", FileSize.format(1))
assertEquals("1023 B", FileSize.format(1023))
}
@Test
fun `kilobytes and above use one decimal`() {
assertEquals("1.0 KB", FileSize.format(1024))
assertEquals("1.5 KB", FileSize.format(1536))
assertEquals("1.0 MB", FileSize.format(1024L * 1024))
assertEquals("1.5 GB", FileSize.format(1536L * 1024 * 1024))
assertEquals("1.0 TB", FileSize.format(1024L * 1024 * 1024 * 1024))
}
}
@@ -0,0 +1,72 @@
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
class NameMatcherTest {
@Test
fun `an empty query matches everything`() {
assertTrue(NameMatcher.matches("anything", ""))
assertTrue(NameMatcher.matches("anything", " "))
}
@Test
fun `a plain query is a case-insensitive substring`() {
assertTrue(NameMatcher.matches("Holiday.JPG", "holiday"))
assertTrue(NameMatcher.matches("Holiday.JPG", "jpg"))
assertFalse(NameMatcher.matches("Holiday.JPG", "beach"))
}
@Test
fun `a query with wildcards is treated as a glob`() {
assertTrue(NameMatcher.matches("IMG_1234.png", "IMG_*.png"))
assertTrue(NameMatcher.matches("IMG_12.png", "IMG_??.png"))
assertFalse(NameMatcher.matches("IMG_123.png", "IMG_??.png"))
assertFalse(NameMatcher.matches("notes.md", "*.txt"))
}
@Test
fun `glob is case-insensitive`() {
assertTrue(NameMatcher.glob("*.PNG", "photo.png"))
}
@Test
fun `glob handles stars against empty and partial text`() {
assertTrue(NameMatcher.glob("*", ""))
assertFalse(NameMatcher.glob("a*", ""))
assertTrue(NameMatcher.glob("a*b*c", "axxbyyc"))
assertFalse(NameMatcher.glob("a*b*c", "axxbyy"))
}
}
class NameAllocatorTest {
@Test
fun `keeps the desired name when it is free`() {
assertEquals("photo.jpg", NameAllocator.uniqueName("photo.jpg") { false })
}
@Test
fun `appends a counter before the extension`() {
val taken = setOf("photo.jpg", "photo (1).jpg")
assertEquals("photo (2).jpg", NameAllocator.uniqueName("photo.jpg") { it in taken })
}
@Test
fun `handles names without an extension`() {
assertEquals("New folder (1)", NameAllocator.uniqueName("New folder") { it == "New folder" })
}
@Test
fun `treats a dotfile as having no extension`() {
assertEquals(".bashrc (1)", NameAllocator.uniqueName(".bashrc") { it == ".bashrc" })
}
@Test
fun `preserves the base of multi-dot names`() {
assertEquals("backup.tar (1).gz", NameAllocator.uniqueName("backup.tar.gz") { it == "backup.tar.gz" })
}
}
@@ -0,0 +1,85 @@
package de.softwareapp_hb.filemanager.core
import org.junit.Assert.assertEquals
import org.junit.Test
class SortSpecTest {
private val file2 = entry("file2.txt", size = 200, date = 20)
private val file10 = entry("file10.txt", size = 100, date = 10)
private val dirA = dir("a")
private val dirB = dir("b")
@Test
fun `directories come first in both directions`() {
val ascending = SortSpec(SortKey.NAME, SortDirection.ASCENDING)
.sort(listOf(file2, dirB, dirA))
assertEquals(listOf(dirA, dirB, file2), ascending)
val descending = SortSpec(SortKey.NAME, SortDirection.DESCENDING)
.sort(listOf(file2, dirB, dirA))
assertEquals(listOf(dirB, dirA, file2), descending)
}
@Test
fun `name sort is natural, so file2 precedes file10`() {
val sorted = SortSpec(SortKey.NAME, SortDirection.ASCENDING).sort(listOf(file10, file2))
assertEquals(listOf(file2, file10), sorted)
}
@Test
fun `size sorts ascending and descending`() {
assertEquals(
listOf(file10, file2),
SortSpec(SortKey.SIZE, SortDirection.ASCENDING).sort(listOf(file2, file10)),
)
assertEquals(
listOf(file2, file10),
SortSpec(SortKey.SIZE, SortDirection.DESCENDING).sort(listOf(file2, file10)),
)
}
@Test
fun `date descending puts the newest first`() {
assertEquals(
listOf(file2, file10),
SortSpec(SortKey.DATE, SortDirection.DESCENDING).sort(listOf(file10, file2)),
)
}
@Test
fun `ties break by name ascending`() {
val a = entry("a.txt", size = 5, date = 0)
val b = entry("b.txt", size = 5, date = 0)
assertEquals(
listOf(a, b),
SortSpec(SortKey.SIZE, SortDirection.DESCENDING).sort(listOf(b, a)),
)
}
private fun entry(name: String, size: Long, date: Long) =
FileEntry(StoragePath.of("/$name"), isDirectory = false, sizeBytes = size, lastModifiedMillis = date)
private fun dir(name: String) =
FileEntry(StoragePath.of("/$name"), isDirectory = true, sizeBytes = 0, lastModifiedMillis = 0)
}
class NaturalOrderTest {
@Test
fun `digit runs compare numerically`() {
assertEquals(true, NaturalOrder.compare("file2", "file10") < 0)
assertEquals(true, NaturalOrder.compare("file10", "file9") > 0)
assertEquals(true, NaturalOrder.compare("a1b2", "a1b10") < 0)
}
@Test
fun `comparison ignores case`() {
assertEquals(0, NaturalOrder.compare("Photo", "photo"))
}
@Test
fun `a prefix sorts before a longer name`() {
assert(NaturalOrder.compare("file", "file2") < 0)
}
}
@@ -0,0 +1,72 @@
package de.softwareapp_hb.filemanager.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class StoragePathTest {
@Test
fun `normalizes separators and dot segments`() {
assertEquals("/a/b/d", StoragePath.of("/a//b/./c/../d").value)
}
@Test
fun `adds a leading slash`() {
assertEquals("/a/b", StoragePath.of("a/b").value)
}
@Test
fun `the root is its own path`() {
assertEquals(StoragePath.Root, StoragePath.of("/"))
assertEquals(StoragePath.Root, StoragePath.of(""))
assertTrue(StoragePath.of("/").isRoot)
assertNull(StoragePath.of("/").parent)
assertEquals("/", StoragePath.of("/").name)
assertTrue(StoragePath.of("/").segments.isEmpty())
}
@Test
fun `cannot escape above the root`() {
assertEquals("/", StoragePath.of("/..").value)
assertEquals("/x", StoragePath.of("/../../x").value)
}
@Test
fun `exposes segments, name and parent`() {
val path = StoragePath.of("/storage/emulated/0/Download")
assertEquals(listOf("storage", "emulated", "0", "Download"), path.segments)
assertEquals("Download", path.name)
assertEquals(StoragePath.of("/storage/emulated/0"), path.parent)
assertEquals(path, path.parent!!.child("Download"))
}
@Test
fun `resolve handles relative and absolute input`() {
val base = StoragePath.of("/a/b")
assertEquals(StoragePath.of("/a/b/c"), base.resolve("c"))
assertEquals(StoragePath.of("/a/c"), base.resolve("../c"))
assertEquals(StoragePath.of("/x"), base.resolve("/x"))
assertEquals(base, base.resolve(""))
}
@Test
fun `ancestor and descendant are strict`() {
val parent = StoragePath.of("/a")
val child = StoragePath.of("/a/b")
assertTrue(parent.isAncestorOf(child))
assertTrue(child.isDescendantOf(parent))
assertFalse(child.isAncestorOf(parent))
assertFalse(parent.isAncestorOf(parent))
assertTrue(StoragePath.Root.isAncestorOf(child))
}
@Test
fun `is a value type`() {
assertEquals(StoragePath.of("/a/b"), StoragePath.of("/a/./b"))
assertEquals(StoragePath.of("/a/b").hashCode(), StoragePath.of("/a/b").hashCode())
assertEquals("/a/b", StoragePath.of("/a/b").toString())
}
}
@@ -0,0 +1,82 @@
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
class TransferPlannerTest {
private val destination = StoragePath.of("/dest")
@Test
fun `no conflict copies straight in`() {
val plan = TransferPlanner.plan(
sources = listOf(file("a.txt"), file("b.txt")),
destination = destination,
policy = ConflictPolicy.SKIP,
exists = { false },
)
assertEquals(listOf(TransferAction.COPY, TransferAction.COPY), plan.map { it.action })
assertEquals(StoragePath.of("/dest/a.txt"), plan[0].target)
assertEquals(StoragePath.of("/dest/b.txt"), plan[1].target)
}
@Test
fun `conflicts follow the chosen policy`() {
val sources = listOf(file("a.txt"))
val exists: (StoragePath) -> Boolean = { it == StoragePath.of("/dest/a.txt") }
assertEquals(
TransferAction.REPLACE,
TransferPlanner.plan(sources, destination, ConflictPolicy.REPLACE, exists).single().action,
)
assertEquals(
TransferAction.SKIP,
TransferPlanner.plan(sources, destination, ConflictPolicy.SKIP, exists).single().action,
)
assertEquals(
TransferAction.ASK,
TransferPlanner.plan(sources, destination, ConflictPolicy.ASK, exists).single().action,
)
val keepBoth = TransferPlanner.plan(sources, destination, ConflictPolicy.KEEP_BOTH, exists).single()
assertEquals(TransferAction.KEEP_BOTH, keepBoth.action)
assertEquals(StoragePath.of("/dest/a (1).txt"), keepBoth.target)
}
@Test
fun `two identical names in one batch do not collide`() {
val plan = TransferPlanner.plan(
sources = listOf(file("a.txt"), file("a.txt")),
destination = destination,
policy = ConflictPolicy.KEEP_BOTH,
exists = { false },
)
assertEquals(StoragePath.of("/dest/a.txt"), plan[0].target)
assertEquals(StoragePath.of("/dest/a (1).txt"), plan[1].target)
}
@Test
fun `a folder cannot be moved into itself or a descendant`() {
assertTrue(TransferPlanner.moveIntoSelf(folder("b"), StoragePath.of("/src/b")))
assertTrue(TransferPlanner.moveIntoSelf(folder("b"), StoragePath.of("/src/b/c")))
assertFalse(TransferPlanner.moveIntoSelf(folder("b"), StoragePath.of("/src")))
assertFalse(TransferPlanner.moveIntoSelf(file("a.txt"), StoragePath.of("/src/a.txt")))
}
@Test
fun `a move into itself is skipped in the plan`() {
val plan = TransferPlanner.plan(
sources = listOf(folder("b")),
destination = StoragePath.of("/src/b"),
policy = ConflictPolicy.REPLACE,
exists = { false },
)
assertEquals(TransferAction.SKIP, plan.single().action)
}
private fun file(name: String) = TransferSource(StoragePath.of("/src/$name"), isDirectory = false)
private fun folder(name: String) = TransferSource(StoragePath.of("/src/$name"), isDirectory = true)
}