M1: pure-Kotlin core engine (paths, classification, sorting, search, transfers) + 44 JVM tests
This commit is contained in:
@@ -12,6 +12,15 @@ upload needs a **higher `versionCode`** than every previous upload.
|
|||||||
|
|
||||||
### Added
|
### 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,
|
- **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
|
`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
|
branded blue theme (dynamic colour on Android 12+), an adaptive launcher icon, and
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ no advertising or tracking SDKs.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**M0 — toolchain & scaffold: done.** The project builds, installs and runs a themed
|
**M1 — pure-Kotlin core: done.** The Android-free `core/` engine (paths, classification,
|
||||||
Compose shell. See [docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone plan.
|
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
|
## Tech stack
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
|
|||||||
| # | Milestone | Status | Evidence / notes |
|
| # | 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| M4 | Compose UI | ⏳ | browser list/grid, breadcrumb + up navigation, sort/filter sheet, multi-select action bar, details sheet, permission prompts, theme |
|
||||||
|
|||||||
Reference in New Issue
Block a user