diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5f9ee..ab7807f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ upload needs a **higher `versionCode`** than every previous upload. ### Added +- **M1 — pure-Kotlin core engine.** An Android-free `core/` package holding the logic + every other layer builds on: `StoragePath` (normalised absolute paths that can never + escape the root), `FileEntry` with extension-based `FileKind` classification and a + `MimeTypes` lookup for "open with", natural-order `SortSpec` (directories always + first, `file2` before `file10`), human-readable `FileSize`, `NameMatcher` + (case-insensitive substring plus `*`/`?` globs), `NameAllocator` ("name (1)") and + `TransferPlanner` (conflict policies and move-into-self detection). Covered by + **44 JVM unit tests** across 10 classes. + - **M0 — project scaffold.** Gradle 9.3.1 / AGP 9.1.0 build with a version catalog, `de.softwareapp_hb.filemanager` app module, a Compose Material 3 shell with the branded blue theme (dynamic colour on Android 12+), an adaptive launcher icon, and diff --git a/README.md b/README.md index 684e794..203293b 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ no advertising or tracking SDKs. ## Status -**M0 — toolchain & scaffold: done.** The project builds, installs and runs a themed -Compose shell. See [docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone plan. +**M1 — pure-Kotlin core: done.** The Android-free `core/` engine (paths, classification, +sorting, formatting, search, name allocation, transfer planning) is implemented and +covered by 44 JVM unit tests. See [docs/ROADMAP.md](docs/ROADMAP.md) for the full +milestone plan. ## Tech stack diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/FileEntry.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/FileEntry.kt new file mode 100644 index 0000000..29a82bd --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/FileEntry.kt @@ -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 diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/FileKind.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/FileKind.kt new file mode 100644 index 0000000..729ba00 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/FileKind.kt @@ -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() + } + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/FileSize.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/FileSize.kt new file mode 100644 index 0000000..d31bcdc --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/FileSize.kt @@ -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]) + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/MimeTypes.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/MimeTypes.kt new file mode 100644 index 0000000..6c44a4c --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/MimeTypes.kt @@ -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) +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/NameAllocator.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/NameAllocator.kt new file mode 100644 index 0000000..6819521 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/NameAllocator.kt @@ -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++ + } + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/NameMatcher.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/NameMatcher.kt new file mode 100644 index 0000000..5e9b686 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/NameMatcher.kt @@ -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 + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/SortSpec.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/SortSpec.kt new file mode 100644 index 0000000..c11f978 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/SortSpec.kt @@ -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): List = entries.sortedWith(comparator) + + val comparator: Comparator + 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 { + + 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) + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/StoragePath.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/StoragePath.kt new file mode 100644 index 0000000..df9b798 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/StoragePath.kt @@ -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 = + 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() + 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 = "/") + } + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/TransferPlanner.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/TransferPlanner.kt new file mode 100644 index 0000000..6ac84b9 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/TransferPlanner.kt @@ -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, + destination: StoragePath, + policy: ConflictPolicy, + exists: (StoragePath) -> Boolean, + ): List { + val reserved = HashSet() + val items = ArrayList(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 + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/FileEntryTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileEntryTest.kt new file mode 100644 index 0000000..a1bb1f4 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileEntryTest.kt @@ -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)), + ) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/FileKindTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileKindTest.kt new file mode 100644 index 0000000..5dcf24d --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileKindTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/FileSizeTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileSizeTest.kt new file mode 100644 index 0000000..ede6b51 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileSizeTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/NameMatcherTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/NameMatcherTest.kt new file mode 100644 index 0000000..3f71828 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/NameMatcherTest.kt @@ -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" }) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/SortSpecTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/SortSpecTest.kt new file mode 100644 index 0000000..7e4cdaa --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/SortSpecTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/StoragePathTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/StoragePathTest.kt new file mode 100644 index 0000000..95c8942 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/StoragePathTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/TransferPlannerTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/TransferPlannerTest.kt new file mode 100644 index 0000000..f0f1d13 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/TransferPlannerTest.kt @@ -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) +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4b52d65..b95fc9d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,7 +10,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | # | Milestone | Status | Evidence / notes | |---|---|---|---| | M0 | Toolchain & scaffold | ✅ | Gradle 9.3.1 + AGP 9.1.0 wrapper, version catalog, app module, Compose theme, debug APK builds, installs and runs on the `phone` AVD: package `de.softwareapp_hb.filemanager.debug`, `versionCode 1`, `minSdk 26`/`targetSdk 36`, **no `INTERNET` permission**, `lintDebug` clean (one expected `UnusedResources` for the not-yet-used notification icon) | -| M1 | Pure-Kotlin core + JVM tests | ⏳ | `StoragePath`/`FileEntry`, sort comparators, name/MIME/category classification, human-readable size/date, search matcher, copy/move planner, conflict policy — `core/` has **no Android imports** | +| M1 | Pure-Kotlin core + JVM tests | ✅ | `core/` is **Android-free**: `StoragePath` (normalised absolute paths that cannot escape the root), `FileEntry` + `FileKind` classification, `MimeTypes`, `NaturalOrder` + `SortSpec` (directories first; `file2` before `file10`), `FileSize`, `NameMatcher` (substring + `*`/`?` glob), `NameAllocator` ("name (1)") and `TransferPlanner` (conflict policies, move-into-self). **44 JVM tests** across 10 classes, all green | | M2 | Storage layer | ⏳ | `StorageSource` interface with `AllFilesSource` (`MANAGE_EXTERNAL_STORAGE`) and `SafSource` (persisted tree URIs); `VolumeRepository` (`StorageManager`/`StorageVolume`, `StatFs`); permission + all-files-access state machine | | M3 | Operations engine + service | ⏳ | create/rename/copy/move/delete, recursive copy with progress + cancellation, conflict resolution, trash via MediaStore, foreground service + progress notification | | M4 | Compose UI | ⏳ | browser list/grid, breadcrumb + up navigation, sort/filter sheet, multi-select action bar, details sheet, permission prompts, theme |