diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..565a672
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,116 @@
+# Working in this repository
+
+**Plain Files** is a file manager for Android: Kotlin and Jetpack Compose, one Gradle module
+(`app`), `minSdk` 26, `targetSdk` 36, and **no `INTERNET` permission** — "nothing leaves the
+device" is a shipping requirement of this app, not a slogan. It is sold once (no ads, no
+subscription, no billing code), and the prose in `docs/` is treated as part of the product.
+
+Two habits shape everything here. **The app is driven on a real device** for anything a user can
+see, and what that proved is written down. **A test name is a sentence** about behaviour, so the
+suite doubles as the specification.
+
+## Read these two first, and only then the source
+
+| file | why |
+|---|---|
+| `docs/CODE_MAP.md` | Generated index of the source tree: what each file holds, and every test name in the suite. Start here for "which file is this in?" |
+| `docs/ROADMAP.md` | Which milestone is done, the key decisions, and the deliberate deferrals |
+
+Do **not** read `CHANGELOG.md` front to back — it is long, and it is meant to be grepped by
+feature name once you know what you are changing.
+
+## Commands
+
+```bash
+./gradlew :app:testDebugUnitTest # the gate: 355 JVM tests
+./gradlew :app:lintDebug # the gate: Android Lint
+./gradlew :app:assembleDebug # then: adb install -r app/build/outputs/apk/debug/app-debug.apk
+./gradlew :app:assembleRelease # R8 is on here, and it has hidden real bugs — see Traps
+
+python3 tools/make-code-map.py # regenerate docs/CODE_MAP.md after touching source
+python3 tools/make-code-map.py --check # fails when the map is stale
+```
+
+There is **no CI**: those two Gradle tasks are the gate, and whoever changes the code runs them.
+Add `--offline` when the network is unavailable — the dependency cache is already warm.
+
+## Where things live, and the one rule about direction
+
+```
+core/ pure Kotlin, no Android imports: paths, names, kinds, sorting, planning
+data/ storage backends (all-files access, SAF), volumes, shares, SQLite index and trash
+domain/ operations over a source: copy/move/delete/rename, archives, search, duplicates
+service/ the foreground service that keeps a long operation alive
+ui/ Compose: browser/, viewer/, usage/, trash/, duplicates/, theme/
+text/ domain enum to string resource
+```
+
+The dependency direction is `ui → domain → data → core`, and `core/` importing `android.*` or
+`androidx.*` is a test failure, not a matter of taste: it is what keeps the trickiest logic
+testable without Robolectric. `docs/CODE_MAP.md` has the per-file detail.
+
+## Non-negotiables
+
+Each of these is enforced somewhere, so breaking one fails a test, lint, or a reviewer's first
+comment. `app/src/test/java/…/ProjectInvariantsTest.kt` holds the source-level ones.
+
+- **No new permission, and never `INTERNET`.** The manifest's permission set is asserted exactly;
+ adding one means deciding it, explaining it in the manifest comment and the README, and updating
+ that list deliberately.
+- **Every user-visible string comes from `strings.xml`.** The debug build enables a pseudo-locale
+ (`values-en-XA`), so untranslated text is visible on screen as unaccented text.
+- **A name is one path segment.** `core/FileNames.isUsableName` is the only implementation; never
+ inspect `/` yourself. `StoragePath.child("a/b")` resolves *into a subfolder*, so an unguarded
+ call renames or creates something the dialog never offered.
+- **Nothing is overwritten silently.** Operations refuse rather than destroy; deletes go through
+ the trash (24-hour window) and offer undo; a batch that cannot succeed is refused as a whole
+ rather than performed until it hits the problem.
+- **Comments explain why, not what.** The house style is a KDoc paragraph per file and type that
+ says what it is for and what was rejected — decisions and limits, not restatements of the code.
+- **A test name is a sentence about behaviour** (`a batch rename is a single undo step`), because
+ the names are read as the spec. Logic gets behaviour tests; rules about the shape of the project
+ get source-level tests.
+- **The docs are part of the change.** `CHANGELOG.md` (Added/Changed/Fixed, in the house voice),
+ the `ROADMAP.md` milestone table, the README if a claim changed, and `docs/EDGE_CASE_TESTING.md`
+ for anything only a device could confirm.
+
+## Traps that have cost real time here
+
+- **MIUI blocks `adb install` twice over.** Developer options needs *Install via USB*, and each
+ install then waits for a confirmation dialog that times out on its own. A bare
+ `INSTALL_FAILED_USER_RESTRICTED` means the dialog was answered late, not that the APK is bad.
+- **Never run `assembleDebug` and `adb install` in one parallel batch.** They race, the install
+ takes the previous APK, and the feature you just wrote appears to be missing.
+- **R8 has hidden real bugs.** A class reached only by reflection, or named only from a resource,
+ is stripped from the release build while debug works perfectly — this happened with `coil-video`,
+ where video frames decoded in debug and quietly fell back to icons for real users. When debug and
+ release disagree, assume the shrinker removed something.
+- **`adb input text` does not deliver a `/`.** Send `input keyevent 76` instead. The slash is worth
+ testing at all because the field *accepts* it and the dialog is what refuses it.
+- **Long-press multi-select via `input swipe x y x y 900` works, but not every time** — it has also
+ landed as a plain tap. A flaky gesture is a reason to look at the screen, not to distrust the code.
+- **The debug build is a separate package** (`…filemanager.debug`), so it installs beside a release
+ build and the two can be compared directly.
+
+## Documentation map — read on demand
+
+| file | what it is for |
+|---|---|
+| `README.md` | The tour: features, layout, build, and the honest **known limitations** |
+| `CHANGELOG.md` | The reasoning behind each change. Grep it; do not read it end to end |
+| `docs/ROADMAP.md` | Milestones with their evidence, the key decisions, the deferrals |
+| `docs/EDGE_CASE_TESTING.md` | What was verified, on which device or AVD, and what was not |
+| `docs/PLAY_RELEASE_CHECKLIST.md` | Release only — work it in order |
+| `docs/COMPETITIVE_ANALYSIS.md`, `docs/MONETISATION.md`, `docs/STORE_LISTING.md` | Product context, pricing, and length-checked listing copy |
+| `PRIVACY.md` | The policy text the store listing points at |
+
+## Definition of done
+
+1. `:app:testDebugUnitTest` and `:app:lintDebug` green.
+2. `python3 tools/make-code-map.py` run, so that `--check` passes.
+3. A `CHANGELOG.md` entry in the house voice: what changed, why, what was rejected, and what was
+ verified rather than assumed.
+4. Anything a user can see driven on a device or emulator, with the result written into
+ `docs/EDGE_CASE_TESTING.md` — including what was deliberately left untested, and why.
+5. A commit whose subject is a sentence about the change and whose body explains the reasoning.
+ The diff already says what changed; the message is there for why it is this way.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afbb7a3..424b2f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,42 @@ upload needs a **higher `versionCode`** than every previous upload.
snackbar's **Undo** put the later batch of three back in one step. The collision and the slash were
checked the same way, both landing with the action disabled and the reason named.
+- **The rules the project states in prose are now checked against the source, and the source has a
+ generated map.** The first question about any change is *which file is this in*, and the three
+ documents that kept being asked to answer it — `README` (the tour), `ROADMAP` (what is done) and
+ `CHANGELOG` (why) — never could. `docs/CODE_MAP.md` does: every one of the 74 source files with
+ the purpose taken from the KDoc already sitting next to it, its size, and the tests that cover it,
+ followed by **every test name in the suite**, which in this project is a sentence about behaviour
+ and therefore the specification itself. It is *generated* by `tools/make-code-map.py` from
+ declarations and comments rather than written by hand, because a hand-written map of 74 files is
+ wrong within a milestone; `--check` fails the moment the file stops matching the tree, and the
+ file says so about itself at the top. `AGENTS.md` is the one page that points at it: the commands,
+ the layout with its dependency direction, the rules that have tests behind them, and the traps
+ that have actually cost time here — MIUI's install dialog, an `assembleDebug` racing an
+ `adb install`, R8 stripping what only a resource names, and that `adb input text` cannot type a
+ `/`.
+
+ The same pass turned four of those stated rules into **failures rather than intentions**.
+ `ProjectInvariantsTest` reads the tree and asserts that `core/` imports no Android, that no
+ Compose `Text(...)` in `ui/` is handed a string literal, that the manifest requests exactly the
+ six permissions the app is built around and never `INTERNET`, and that separator handling lives in
+ `core/` alone. Three of the four were already true and can no longer quietly stop being true. The
+ fourth caught a bug: **"New folder" accepted a name that was a path.** `createFolder` checked only
+ for empty, so `notes/final` was resolved by `StoragePath` into the `notes` folder that happened to
+ exist — the dialog offered one folder and the app made a different one — while Rename refused the
+ same input, because the rule had exactly one implementation and only one of the two callers used
+ it.
+
+ Both now go through `core/FileNames.isUsableName`, and the shared name dialog **says** why a name
+ is refused — *A name cannot contain a slash*, while it is typed, with its button disabled —
+ instead of appearing to do nothing when it is pressed. The batch dialog and the two single-name
+ dialogs share that one string, so the rule reads the same wherever a name is entered. Verified on
+ the phone: with `a/b` in the field the note appears and Create is the only disabled node in the
+ dump; deleting the slash clears the note and re-enables it. **355 JVM tests** (5 added), lint
+ clean — and the map's own convention is visible inside it: 15 of the 74 files take their purpose
+ from the first KDoc rather than from a declaration sharing the file's name, and the generated file
+ lists those at the end instead of pretending otherwise.
+
### Changed
- **File thumbnails are bigger.** The leading visual in a list row went from 28 dp to 48 dp and
diff --git a/README.md b/README.md
index 3a4c41f..2252810 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,13 @@ app/src/test/java/… JVM unit tests for the core engine
`core/` deliberately has **no Android dependencies**, so the trickiest logic is
covered by fast JVM tests.
+The per-file index — what each file holds, and every test name in the suite — is
+[`docs/CODE_MAP.md`](docs/CODE_MAP.md), generated from the source by
+`tools/make-code-map.py` (`--check` fails when it is stale). [`AGENTS.md`](AGENTS.md) is the
+one-page briefing for whoever picks the repository up: the commands, the layout and its
+dependency direction, the rules that have tests behind them, and the traps worth knowing
+before spending an hour on them.
+
## Building
Prerequisites:
@@ -87,8 +94,8 @@ R8/minification but produces an unsigned artefact.
## Tracked in git
- Source, resources and Gradle build files.
-- `docs/` — roadmap and release documentation.
-- `PRIVACY.md`, `CHANGELOG.md`, and (from M10) `store-assets/`.
+- `docs/` — the roadmap, the generated code map and the release documentation.
+- `PRIVACY.md`, `CHANGELOG.md`, `AGENTS.md`, and (from M10) `store-assets/`.
Not tracked: `local.properties`, `keystore.properties`, `*.jks`, build outputs.
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt
index 93a7020..50127cb 100644
--- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt
@@ -27,6 +27,7 @@ import de.softwareapp_hb.filemanager.core.BatchRename
import de.softwareapp_hb.filemanager.core.BatchRenamePlan
import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileEntry
+import de.softwareapp_hb.filemanager.core.isUsableName
/**
* Renaming several files at once, with the numbering worked out before anything moves.
@@ -136,7 +137,7 @@ private const val PREVIEW_NAMES = 3
@Composable
private fun batchRenameNote(base: String, usable: Boolean, plan: BatchRenamePlan?): String = when {
base.isEmpty() -> ""
- !usable -> stringResource(R.string.browser_rename_many_bad_name)
+ !usable -> stringResource(R.string.browser_name_bad)
plan is BatchRenamePlan.Blocked ->
stringResource(R.string.browser_rename_many_taken, plan.taken.first())
@@ -165,6 +166,12 @@ fun NameDialog(
onConfirm: (String) -> Unit,
) {
var value by remember { mutableStateOf(initialValue) }
+ val cleaned = value.trim()
+ // The same rule the batch dialog applies, because it is the same rule: a name is one path
+ // segment. Saying it here is the difference between a dialog that refuses and one that
+ // appears to do nothing — a name with a slash in it used to be accepted by the button and
+ // then quietly dropped by the view model.
+ val usable = isUsableName(cleaned)
AlertDialog(
onDismissRequest = onDismiss,
@@ -176,18 +183,27 @@ fun NameDialog(
onValueChange = { value = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
+ isError = value.isNotBlank() && !usable,
label = { Text(stringResource(R.string.browser_name_label)) },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
)
+ if (value.isNotBlank() && !usable) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.browser_name_bad),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
}
},
confirmButton = {
TextButton(
- onClick = { onConfirm(value) },
- enabled = value.isNotBlank(),
+ onClick = { onConfirm(cleaned) },
+ enabled = cleaned.isNotBlank() && usable,
) {
Text(confirmLabel)
}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt
index 54e4b35..56f28e5 100644
--- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt
@@ -1632,7 +1632,10 @@ class BrowserViewModel(
val target = _state.value.path ?: return
val source = storage.activeSource() ?: return
val cleanName = name.trim()
- if (cleanName.isEmpty()) return
+ // The same rule a rename needs, for the same reason: a name is one path segment, so
+ // `notes/final` must be refused rather than resolved into the `notes` folder that happens
+ // to be sitting there — the dialog offered one new folder, not a folder inside a subfolder.
+ if (!isUsableName(cleanName)) return
viewModelScope.launch {
val created = runCatching { source.createDirectory(target.child(cleanName)) }.getOrDefault(false)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 97f75ec..31a283a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -159,7 +159,7 @@
%1$s is already in this folder
Every selected file already has that name
- A name cannot contain a slash
+ A name cannot contain a slash
- Renamed %1$d file
- Renamed %1$d files
diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ProjectInvariantsTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ProjectInvariantsTest.kt
new file mode 100644
index 0000000..005f491
--- /dev/null
+++ b/app/src/test/java/de/softwareapp_hb/filemanager/ProjectInvariantsTest.kt
@@ -0,0 +1,157 @@
+package de.softwareapp_hb.filemanager
+
+import java.io.File
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The rules this project states in prose, checked against the source instead of trusted.
+ *
+ * Each of them is a sentence that already appears in the README, the CHANGELOG or a KDoc, and each
+ * is the kind of claim that stays true for a year and then quietly stops being true — a stray
+ * `import android.` in `core/`, a hardcoded label in a dialog, a permission added for something
+ * that seemed to need it. The failure message is meant to be the documentation: it says which rule
+ * broke, why the rule exists and what to do instead.
+ *
+ * These are source-level tests rather than behaviour, so they read the tree and need a working
+ * directory inside the repository. Gradle's `Test` task provides one; if it ever does not, that is
+ * reported as a failure rather than passed silently, because an invariant test that cannot see the
+ * source is worse than no test at all.
+ */
+class ProjectInvariantsTest {
+
+ @Test
+ fun `core is pure Kotlin, with no Android imports`() {
+ val offenders = kotlinFiles(mainDir("core"))
+ .flatMap { file -> imports(file).map { file.name to it } }
+ .filter { (_, line) ->
+ line.startsWith("import android.") || line.startsWith("import androidx.")
+ }
+ assertTrue(
+ "core/ is the engine the JVM tests reach without a framework, and the README says so " +
+ "in as many words. An Android import here means the logic it holds can no longer " +
+ "be tested without Robolectric, so either move the platform call up into data/ or " +
+ "ui/, or give core/ a plain-Kotlin seam. Found: " +
+ offenders.joinToString { (name, line) -> "$name -> $line" },
+ offenders.isEmpty(),
+ )
+ }
+
+ @Test
+ fun `user-visible text comes from string resources, not from literals`() {
+ // Deliberately narrow: this catches a literal as the *whole* text of a `Text(...)`, which
+ // is the way a hardcoded label usually arrives. It does not catch a literal concatenated
+ // into resource text (" · " between two formatted halves), because seeing that would take
+ // a Kotlin parser rather than a line scan — and a rule that pretends otherwise would be
+ // worse than one that states its own limit.
+ val literal = Regex("\\bText\\(\\s*\"")
+ val offenders = kotlinFiles(mainDir("ui"))
+ .flatMap { file -> codeLines(file).map { file.name to it.trim() } }
+ .filter { (_, line) -> literal.containsMatchIn(line) }
+ assertTrue(
+ "Every string a user reads has to come from strings.xml: it is what the pseudo-locale " +
+ "build (values-en-XA) exercises, and it is what the localisation section of the " +
+ "README promises. Use stringResource(R.string.…) instead. Found: " +
+ offenders.joinToString { (name, line) -> "$name -> $line" },
+ offenders.isEmpty(),
+ )
+ }
+
+ @Test
+ fun `the app asks for no permission that could reach the network`() {
+ val manifest = repositoryRoot().resolve("app/src/main/AndroidManifest.xml").readText()
+ val declared = Regex(" kotlinFiles(dir) }
+ .flatMap { file -> codeLines(file).map { file.name to it.trim() } }
+ .filter { (_, line) -> separator.containsMatchIn(line) }
+ assertTrue(
+ "A name that contains a separator is not a name: `StoragePath.child(\"a/b\")` resolves " +
+ "into a subfolder, so an unguarded call renames or creates something other than " +
+ "what the dialog offered. Ask core/FileNames.isUsableName instead of inspecting " +
+ "the string here. Found: " +
+ offenders.joinToString { (name, line) -> "$name -> $line" },
+ offenders.isEmpty(),
+ )
+ }
+
+ // ------------------------------------------------------------------ reading the tree
+
+ private fun repositoryRoot(): File {
+ var candidate = File("").absoluteFile
+ while (true) {
+ if (File(candidate, "settings.gradle.kts").isFile) return candidate
+ candidate = candidate.parentFile
+ ?: throw AssertionError(
+ "Could not find settings.gradle.kts above ${File("").absolutePath}. These " +
+ "tests read the source tree, so the working directory has to be inside " +
+ "the repository — Gradle's Test task normally sets one.",
+ )
+ }
+ }
+
+ private fun mainDir(vararg nested: String): File {
+ var dir = repositoryRoot().resolve("app/src/main/java/de/softwareapp_hb/filemanager")
+ for (part in nested) dir = dir.resolve(part)
+ return dir
+ }
+
+ private fun kotlinFiles(dir: File): List =
+ (dir.listFiles() ?: emptyArray()).sorted().flatMap { child ->
+ when {
+ child.isDirectory -> kotlinFiles(child)
+ child.extension == "kt" -> listOf(child)
+ else -> emptyList()
+ }
+ }
+
+ /** The lines of [file] that are code: a rule stated in a comment is documentation, not a bug. */
+ private fun codeLines(file: File): List =
+ file.readLines().filterNot { line ->
+ val trimmed = line.trimStart()
+ trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")
+ }
+
+ private fun imports(file: File): List =
+ codeLines(file).map { it.trim() }.filter { it.startsWith("import ") }
+}
\ No newline at end of file
diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt
index 660ff59..be413d4 100644
--- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt
+++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt
@@ -939,6 +939,21 @@ class BrowserViewModelTest {
assertTrue(model.state.value.entries.any { it.name == "New folder" })
}
+ @Test
+ fun `createFolder refuses a name that is a path`() {
+ // `notes/final` resolves to `notes/final`, so an unguarded create would make a folder
+ // *inside* `notes` — while the dialog offered one folder in the folder on screen.
+ val model = viewModel()
+ model.openDirectory(downloads)
+ storage.addDirectory(downloads.child("notes"))
+
+ model.createFolder("notes/final")
+
+ assertFalse(storage.contains(downloads.child("notes").child("final")))
+ assertNull("the refusal is the dialog's to explain, not a message after the fact",
+ model.state.value.message)
+ }
+
@Test
fun `rename moves the entry to the new name`() {
val model = viewModel()
diff --git a/docs/CODE_MAP.md b/docs/CODE_MAP.md
new file mode 100644
index 0000000..1e00b7b
--- /dev/null
+++ b/docs/CODE_MAP.md
@@ -0,0 +1,614 @@
+# Code map
+
+Generated from the source by `tools/make-code-map.py` - do not edit by hand. Regenerate it after a change (`python3 tools/make-code-map.py`); `--check` exits non-zero when this file is stale.
+
+74 source files, 12036 lines - 28 test files, 355 tests. The behaviour index at the end lists every test name, and in this project a test name is a sentence about what the app does, so it doubles as the specification.
+
+Start at `AGENTS.md`. What is done, and why: `docs/ROADMAP.md` and `CHANGELOG.md`. What was verified by hand rather than by a test: `docs/EDGE_CASE_TESTING.md`.
+
+## core/
+
+Pure Kotlin, **no Android imports**: paths, file names, kinds, sorting, formatting, matching, the batch-rename planner
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `ArchiveFormats.kt` | 102 | Which archive formats the app can handle, by file name. ArchiveFormat, ArchiveFormats | `ArchivePathTest` (18) |
+| `ArchivePath.kt` | 43 | Guards archive extraction against writing outside the destination folder. ArchivePath | `ArchivePathTest` (18) |
+| `BatchRename.kt` | 125 | Renames a batch of files to one name with a number after each: Holiday (1).jpg, Holiday (2).jpg, and so on. BatchRenameMove, BatchRenamePlan, BatchRename | `BatchRenameTest` (16) |
+| `FileCategory.kt` | 53 | A library the home screen can open: everything on the device of a given kind, rather than everything in a given folder. FileCategory | `FileCategoryTest` (6) |
+| `FileEntry.kt` | 29 | One row in a directory listing. FileEntry, isHiddenName() | `FileEntryTest` (8) |
+| `FileKind.kt` | 73 | The coarse category a file belongs to, derived from its name. FileKind | `FileKindTest` (6) |
+| `FileNames.kt` | 13 | Whether name can be the name of a file in a folder. isUsableName() | `FileNamesTest` (4) |
+| `FileSize.kt` | 27 | Human-readable byte sizes for the file list ("1.5 MB"). FileSize | `FileSizeTest` (2) |
+| `MimeTypes.kt` | 110 | Best-effort MIME lookup for the "open with" intent. MimeTypes | `FileEntryTest` (8) |
+| `NameAllocator.kt` | 30 | Picks a name that is not already in use, in the familiar "name (1)" style. NameAllocator | `NameMatcherTest` (10) |
+| `NameMatcher.kt` | 63 | The in-folder search matcher. NameMatcher | `NameMatcherTest` (10) |
+| `SortSpec.kt` | 82 | A complete, serialisable description of the ordering of a directory listing. SortKey, SortDirection, SortSpec, NaturalOrder | `SortSpecTest` (8) |
+| `StoragePath.kt` | 89 | A normalized, absolute, backend-agnostic storage path. StoragePath | `StoragePathTest` (8) |
+| `Timestamps.kt` | 20 | Timestamps for the details sheet. Timestamps | - |
+| `TransferPlanner.kt` | 105 | Turns "copy/paste these items into that folder" into a per-item decision, before a single byte is written. ConflictPolicy, TransferAction, TransferSource, TransferItem, TransferPlanner | `TransferPlannerTest` (5) |
+
+## data/
+
+Storage backends (all-files access and SAF), volumes, incoming shares, the SQLite search index and trash index
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `AllFilesSource.kt` | 125 | The "All files access" backend: plain java.io.File over absolute paths. AllFilesSource, File() | `AllFilesSourceTest` (13) |
+| `ApkInspector.kt` | 100 | What an APK says about itself, read without installing it. ApkInfo, interface(), PackageManagerApkInspector | - |
+| `IncomingSource.kt` | 48 | Something another app has handed to this one. IncomingSource, ContentResolverIncomingSource | - |
+| `MediaStoreDeleter.kt` | 73 | A best-effort DeleteRequestFactory backed by MediaStore. interface(), MediaStoreDeleter | - |
+| `SafDocumentIds.kt` | 40 | Maps a Storage Access Framework tree onto StoragePaths. SafDocumentIds | `SafDocumentIdsTest` (6) |
+| `SafGrant.kt` | 41 | Picking and keeping a folder tree for the SAF fallback. SafGrant | - |
+| `SafSource.kt` | 187 | The Storage Access Framework fallback backend. SafSource | `SafSourceTest` (5) |
+| `SearchIndex.kt` | 196 | A by-name index of what is on the volumes, so a search does not have to walk them. IndexedFile, VolumeStamp, SearchIndex | - |
+| `SqliteSearchIndex.kt` | 438 | The search index, on SQLite. SqliteSearchIndex | `SqliteSearchIndexTest` (27) |
+| `SqliteTrashIndex.kt` | 162 | The trash index, on SQLite. SqliteTrashIndex | `SqliteTrashIndexTest` (7) |
+| `StorageAccess.kt` | 80 | The Android-facing side of the storage-access decision. StorageAccess | - |
+| `StorageAccessPolicy.kt` | 68 | The decision rules behind StorageAccess. StorageAccessMode, StorageAccessPolicy | `StorageAccessPolicyTest` (6) |
+| `StorageLocation.kt` | 87 | Everything the browser needs to know about where files live and what it may touch. StorageLocation, StorageProvider | - |
+| `StorageSource.kt` | 96 | The primitive file operations a storage backend must provide. StorageException, StorageSource | - |
+| `TrashIndex.kt` | 61 | The durable memory of what is in the trash. TrashRecord, TrashIndex | - |
+| `VolumeRepository.kt` | 121 | Discovers the device's storage volumes and reports their space. VolumeInfo, SpaceInfo, VolumeRepository | `VolumeRepositoryTest` (6) |
+
+## domain/
+
+Operations over a source: copy/move/delete/rename, archives, search walking, duplicate finding, trash, usage analysis
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `ArchiveEngine.kt` | 658 | Creates and extracts archives, on top of the storage backends. ArchiveEntry, ArchiveEngine | `ArchiveEngineTest` (30) |
+| `DuplicateFinder.kt` | 202 | Finds files that hold the same bytes. DuplicateGroup, DuplicateProgress, DuplicateScan, DuplicateFinder | `DuplicateFinderTest` (8) |
+| `FileOperations.kt` | 272 | Copy, move and delete, assembled from the StorageSource primitives. FileOperations | `FileOperationsTest` (19) |
+| `FileSearch.kt` | 79 | Recursive name search. FileSearch | `FileSearchTest` (8) |
+| `OperationModels.kt` | 42 | The kind of long-running operation a progress notification can describe. OperationKind, OperationProgress, OperationFailure, OperationResult | - |
+| `OperationNotifier.kt` | 25 | Keeps the foreground service in step with a running operation. OperationNotifier | - |
+| `SearchIndexer.kt` | 173 | Fills a SearchIndex by walking a volume. SearchIndexer | `SearchIndexerTest` (14) |
+| `Trash.kt` | 338 | A staging folder that makes a delete undoable. TrashedEntry, TrashBatch, Trash | `TrashTest` (19) |
+| `UsageAnalysis.kt` | 95 | Works out where the space went. UsageNode, UsageAnalysis | `UsageAnalysisTest` (6) |
+
+## service/
+
+The foreground service that keeps a long operation alive and reports it
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `FileOpNotifications.kt` | 64 | Builds the progress notification for a running file operation. FileOpNotifications | - |
+| `FileOpService.kt` | 107 | Keeps a long copy, move or delete alive while the app is backgrounded, and mirrors the engine's progress into an ongoing notification. FileOpService | `FileOpServiceTest` (4) |
+| `ServiceOperationNotifier.kt` | 19 | The production OperationNotifier: forwards to FileOpService, which owns the foreground notification and keeps the process alive. ServiceOperationNotifier | - |
+
+## text/
+
+Domain enum to string-resource mapping
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `TextRes.kt` | 58 | Maps domain enums onto their resource ids. OperationKind(), FileKind(), FileCategory() | - |
+
+## ui/browser/
+
+The browser screen: view model, state, list and grid, dialogs, selection bar
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `BrowserComponents.kt` | 301 | The thumbnail an entry's leading visual gets in a list row. ListThumbnailSize, GridThumbnailSize, GridCellMinSize, EntryIcon(), FileEntry(), FileEntry(), VIDEO_FRAME_MILLIS, iconFor(), iconFor(), EntryRow(), EntryGridCell(), FileEntry() | - |
+| `BrowserContent.kt` | 420 | Everything below the app bar: the permission prompt, the roots, or a directory. WIDE_LAYOUT_MIN_WIDTH, MasterPaneWidth, BrowserContent(), LazyListScope(), LIBRARY_SECTION_KEY, DirectoryView(), FileEntry(), IndexStatusRow(), FileEntry() | - |
+| `BrowserDetailPane.kt` | 131 | The second pane of the wide layout: what is inside the folder the user picked, or a preview and the details of the file they picked. DetailPane(), FolderPreview(), ImagePreview() | - |
+| `BrowserDialogs.kt` | 279 | Renaming several files at once, with the numbering worked out before anything moves. BatchRenameDialog(), PREVIEW_NAMES, batchRenameNote(), NameDialog(), ConfirmDialog(), ConflictDialog() | - |
+| `BrowserRoots.kt` | 193 | A row on the root screen: a volume, with its free space when the platform reports it. RootRow(), itemCountLabel(), EmptyState(), LibrarySectionHeader(), LibraryRow(), Breadcrumbs() | - |
+| `BrowserScreen.kt` | 585 | BrowserScreen() | - |
+| `BrowserTopBar.kt` | 210 | The browser's app bar: title, up, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | - |
+| `BrowserUiState.kt` | 287 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, UiMessage, ConflictPrompt, RootItem, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - |
+| `BrowserViewModel.kt` | 2011 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (82) |
+| `DetailsSheet.kt` | 109 | Name, location, size, type and modified date for one entry. DetailsSheet(), DetailLines(), DetailLine() | - |
+| `EntryIntents.kt` | 58 | Wraps an entry's path in a content:// URI another app may read. contentUriFor(), viewIntent(), shareIntent() | - |
+| `IncomingBar.kt` | 60 | The bar shown while another app is waiting on this one. IncomingBar() | - |
+| `IncomingRequest.kt` | 46 | What another app asked this one to do. IncomingRequest | - |
+| `SelectionBar.kt` | 176 | The contextual action bar shown while entries are selected. StorageAccessPrompt(), SelectionBar() | - |
+| `SortSheet.kt` | 128 | Sort key, direction, hidden files and list/grid — all in one sheet. sortKeyLabel(), SortSheet() | - |
+
+## ui/viewer/
+
+The built-in viewers (text, image, PDF, media, markup, archive contents)
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `ApkInfoScreen.kt` | 201 | What an APK says about itself, read from its own manifest without installing it. ApkInfoScreen(), ApkDetails(), ApkLine() | - |
+| `ArchivePreviewScreen.kt` | 198 | What an archive holds, read from its index without unpacking any of it. ArchivePreviewScreen(), ArchiveEntryRow(), INDENT_DP | - |
+| `ImageViewerScreen.kt` | 64 | The built-in image viewer. ImageViewerScreen() | - |
+| `MarkupPreviewScreen.kt` | 116 | Markup drawn by the platform's own web engine: rendered HTML, and the SVG that the raster image viewer cannot decode at all. MarkupPreviewScreen(), ENCODING | - |
+| `MediaPlayerScreen.kt` | 250 | Audio and video, played from the file itself. MediaPlayerScreen(), VideoPreview(), AudioPreview(), Unplayable(), timeLabel(), POSITION_POLL_MILLIS | - |
+| `PdfViewerScreen.kt` | 276 | A PDF, drawn a page at a time by the platform's own renderer. OpenPdf, PdfViewerScreen(), PdfPages(), PdfPage(), PageBar() | - |
+| `TextEditorScreen.kt` | 79 | A plain text editor for the text formats the browser classifies as FileKind.TEXT. TextEditorScreen() | - |
+
+## ui/usage/
+
+The storage-usage screen
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `UsageScreen.kt` | 146 | Where the space went: each child of the current folder with its recursive size, biggest first, and a bar showing its share of the folder. UsageScreen(), UsageRow() | - |
+
+## ui/trash/
+
+The trash screen
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `TrashScreen.kt` | 192 | What the trash still holds, and the way to put it back. TrashScreen(), BatchHeader(), TrashEntryRow() | - |
+
+## ui/duplicates/
+
+The duplicate-finder screen
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `DuplicateScreen.kt` | 246 | The duplicate scan: what was compared, what matched, and which copies to remove. DuplicateScreen(), ScanRow(), LazyListScope(), GroupHeader(), EmptyDuplicates() | - |
+
+## ui/theme/
+
+Compose theme, colours, type
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `Color.kt` | 27 | BluePrimaryLight, BlueOnPrimaryLight, BlueContainerLight, BlueOnContainerLight, TealSecondaryLight, SurfaceLight, SurfaceVariantLight, OnSurfaceLight, ErrorLight, BluePrimaryDark, BlueOnPrimaryDark, BlueContainerDark, BlueOnContainerDark, TealSecondaryDark, SurfaceDark, SurfaceVariantDark, OnSurfaceDark, ErrorDark | - |
+| `Theme.kt` | 64 | App theme. LightColors, DarkColors, FileManagerTheme() | - |
+| `Type.kt` | 9 | The app sticks with the Material 3 default type scale; a dedicated object keeps a single place to tune typography later. FileManagerTypography | - |
+
+## ./
+
+| file | lines | what it holds | tests |
+|---|---|---|---|
+| `FileManagerApplication.kt` | 90 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
+| `MainActivity.kt` | 65 | MainActivity | - |
+
+## Behaviour index
+
+Every test name in the suite, by file: the fastest way to see whether something is already covered, and what the app claims to do.
+
+### `ProjectInvariantsTest.kt` (4)
+
+- core is pure Kotlin, with no Android imports
+- user-visible text comes from string resources, not from literals
+- the app asks for no permission that could reach the network
+- a name is one path segment, and that rule lives in one place
+
+### `core/ArchivePathTest.kt` (18)
+
+- plain names are kept as they are
+- redundant segments are normalized away
+- absolute paths are rejected
+- windows drive letters are rejected
+- climbing out of the destination is rejected
+- backslashes are treated as separators
+- empty and dot-only names are rejected
+- a climb that stays inside the destination is allowed
+- an unsafe name never yields a path with a parent segment
+- detects the formats it can extract
+- detects 7z, which is readable without native code
+- recognises but refuses formats that would need native code
+- an ordinary file is not an archive
+- detection is case-insensitive and tgz wins over gz
+- single-file wrappers lose their own suffix
+- a compressed tar is recognised as a tar, not as a bare compressed file
+- every compressed tar names the tar inside it
+- only zip can be created
+
+### `core/BatchRenameTest.kt` (16)
+
+- a second batch carries on where the first stopped
+- a file named exactly like the base counts as number zero
+- it continues from the highest number rather than from the first gap
+- the series is counted across extensions
+- names that merely resemble the pattern are ignored
+- a base that contains regex characters is matched literally
+- an empty folder starts at one
+- a plan keeps each file's own extension and numbers it in the order shown
+- a plan can continue the folder's series
+- a name that is already in the folder blocks the whole batch
+- a chain through a selected file is refused rather than half-done
+- files that already have the pattern's names are left alone
+- a file that already has its target name is counted and skipped, not renamed to itself
+- a start below one is treated as one, because there is no zeroth copy
+- a file with no extension gains none
+- a base has to be a name, not a path
+
+### `core/FileCategoryTest.kt` (6)
+
+- the kinds that belong to no library are exactly the three that were decided
+- documents covers text as well as documents
+- every kind maps back to the library that claims it
+- an uncollected kind answers null
+- recent is the only library that is not about a kind
+- no kind is claimed by two libraries
+
+### `core/FileEntryTest.kt` (8)
+
+- derives name and kind from the path
+- hidden follows the leading-dot convention
+- maps known extensions
+- falls back to the binary type
+- directories report the directory type
+- an intent takes the type its items share
+- a mixed batch falls back to the wildcard rather than hiding half the apps
+- an unknown extension is still shared as a file
+
+### `core/FileKindTest.kt` (6)
+
+- a directory wins over its extension
+- classifies common extensions case-insensitively
+- only the last extension counts
+- apk is recognised separately from archives
+- unknown and hidden names are other
+- extensionOf ignores dotfiles and trailing dots
+
+### `core/FileNamesTest.kt` (4)
+
+- an ordinary name is usable
+- a separator is not part of a name
+- a name has to say something
+- a null byte cannot be in a name
+
+### `core/FileSizeTest.kt` (2)
+
+- bytes are shown without decimals
+- kilobytes and above use one decimal
+
+### `core/NameMatcherTest.kt` (10)
+
+- an empty query matches everything
+- a plain query is a case-insensitive substring
+- a query with wildcards is treated as a glob
+- glob is case-insensitive
+- glob handles stars against empty and partial text
+- keeps the desired name when it is free
+- appends a counter before the extension
+- handles names without an extension
+- treats a dotfile as having no extension
+- preserves the base of multi-dot names
+
+### `core/SortSpecTest.kt` (8)
+
+- directories come first in both directions
+- name sort is natural, so file2 precedes file10
+- size sorts ascending and descending
+- date descending puts the newest first
+- ties break by name ascending
+- digit runs compare numerically
+- comparison ignores case
+- a prefix sorts before a longer name
+
+### `core/StoragePathTest.kt` (8)
+
+- normalizes separators and dot segments
+- adds a leading slash
+- the root is its own path
+- cannot escape above the root
+- exposes segments, name and parent
+- resolve handles relative and absolute input
+- ancestor and descendant are strict
+- is a value type
+
+### `core/TransferPlannerTest.kt` (5)
+
+- no conflict copies straight in
+- conflicts follow the chosen policy
+- two identical names in one batch do not collide
+- a folder cannot be moved into itself or a descendant
+- a move into itself is skipped in the plan
+
+### `data/AllFilesSourceTest.kt` (13)
+
+- lists the children of a directory
+- names lists a folder without reading metadata
+- names of a folder that cannot be read is null, not empty
+- names of a file is null too, because a file holds no names
+- listing a missing directory yields nothing
+- stat and exists agree
+- creates missing directories and empty files
+- delete removes a tree recursively
+- delete reports false for something that is not there
+- rename moves within the source and refuses to overwrite
+- writing then reading round-trips, and append works
+- free space comes from the injected probe
+- reports its backend name
+
+### `data/SafDocumentIdsTest.kt` (6)
+
+- the root maps onto the tree document id
+- children append their relative path
+- every path round-trips
+- documents outside the tree are rejected
+- labels are derived from the tree id
+- trees are not confused with similarly prefixed ones
+
+### `data/SafSourceTest.kt` (5)
+
+- lists the children of the tree root
+- stats a single document
+- a document the provider does not know does not exist
+- exposes a label and exactly one root
+- reports no free space, since the framework cannot
+
+### `data/SqliteSearchIndexTest.kt` (27)
+
+- a plain query is a substring of the name, case-insensitively
+- a query with a wildcard is a glob over the whole name
+- wildcards the user did not type stay literal
+- results are capped and ordered by name
+- a volume is replaced wholesale, and the stalest one decides the age
+- a half-finished pass leaves the old timestamp in place
+- forgetting a row takes it out of the results
+- clearing empties the rows and the timestamps
+- nothing indexed yet reports no age at all
+- a schema bump with no migration reports itself
+- children returns one folder's rows and nothing deeper
+- forgetting a folder takes its subtree and leaves its neighbours
+- forgetting a folder whose name contains a wildcard takes only that subtree
+- a catch-up keeps the time of the last full walk
+- the stamp remembers which hidden-files preference built it
+- a library query returns only the kinds it was asked for
+- a library query is newest first, so the cap keeps the newest
+- a library of two kinds holds both of them
+- a library that collects no kind answers nothing rather than everything
+- recent is ordered by date and leaves folders out
+- counts are grouped by kind
+- an index that has never been filled counts nothing
+- migrating from version 1 keeps the rows and adds the new columns
+- migrating from version 2 rebuilds the files table and forgets the stamps
+- duplicate candidates share a size, largest first
+- folders are never candidates, however their size falls out
+- the floor and the cap bound the candidate list
+
+### `data/SqliteTrashIndexTest.kt` (7)
+
+- a recorded item reads back whole
+- recording the same staged path twice replaces instead of duplicating
+- records come back newest batch first
+- forgetting one batch leaves the others alone
+- an empty index reads back empty rather than throwing
+- a schema bump with no migration reports itself instead of emptying the index
+- an already current schema is left alone
+
+### `data/StorageAccessPolicyTest.kt` (6)
+
+- android 11 and up key off the all-files special permission
+- android 10 and below key off the legacy write grant
+- from android 13 notifications are the only grant left to ask for
+- storage grants are asked for up to android 12
+- already granted permissions are not requested again
+- the permission constants match the platform strings
+
+### `data/VolumeRepositoryTest.kt` (6)
+
+- volumes are listed primary first
+- roots are built as directories in order
+- space is summed across volumes
+- an unknown volume reports no space
+- a volume without a space reading contributes nothing
+- used bytes never go negative
+
+### `domain/ArchiveEngineTest.kt` (30)
+
+- a zip round-trips folders and files
+- createZip reports progress over the files it will write
+- a zip-slip entry cannot escape the destination
+- a single-file gzip is unwrapped to its inner name
+- a single-file xz is unwrapped, which needs the optional xz library
+- a plain tar round-trips its files
+- a gzip tar round-trips its files
+- a bzip2 tar round-trips its files
+- an xz tar round-trips its files
+- an unsupported format is refused rather than guessed at
+- a 7z round-trips its files
+- a 7z-slip entry cannot escape the destination
+- 7z is refused with a readable reason when the backend cannot seek
+- extracting something that is not there is reported
+- listing reads names, sizes and nesting without extracting anything
+- a listing is the same when the backend cannot seek
+- a 7z is listed from its index
+- a compressed file is listed as the one name inside it
+- an unsupported archive cannot be listed either
+- one entry can be extracted without unpacking the rest
+- one entry can be extracted when the backend cannot seek
+- one entry can be extracted from a 7z
+- extracting a single hostile entry is refused
+- an entry can be read without extracting anything
+- an entry can be read when the backend cannot seek
+- an entry can be read from a 7z, which has no stream per entry
+- an entry inside a compressed wrapper is read under the name its archive implies
+- reading refuses to hand out more than the limit
+- an entry can be written straight to a file outside the backend
+- reading an entry the archive does not hold is null
+
+### `domain/DuplicateFinderTest.kt` (8)
+
+- files holding the same bytes are one group, and the group says what it would give back
+- three copies are one group of three
+- files of the same length but different contents are never a group
+- files that share a beginning but differ later are caught by the full pass
+- a file that cannot be read is counted rather than guessed at
+- groups are ordered by the space they would give back
+- a file whose length nothing else shares is never read
+- nothing to compare is nothing to report
+
+### `domain/FileOperationsTest.kt` (19)
+
+- copy brings a file across with its content
+- copy recurses into folders
+- copy creates the target folder when it does not exist
+- copy reports progress that reaches the total
+- keep both renames the incoming copy
+- skip leaves the existing file untouched
+- replace overwrites the existing file
+- ask is surfaced to the caller and skipped by the engine
+- move renames within the backend
+- move falls back to copy and delete when rename is unavailable
+- move keeps the source when the copy failed
+- a failing file is reported and the batch continues
+- an item that vanished is reported
+- cancellation stops between entries and reports partial work
+- delete removes files and folders
+- delete reports an item that is already gone
+- rename falls back to a copy and delete
+- rename refuses to clobber an existing target
+- createDirectory makes the folder
+
+### `domain/FileSearchTest.kt` (8)
+
+- finds a match however deep it is
+- a hidden folder is skipped whole, not just hidden from the results
+- hidden entries are searched when the preference asks for them
+- a matching folder is still descended into
+- matches stream out as they are found
+- the walk stops at the limit
+- globs work as they do everywhere else
+- a query that matches nothing finds nothing
+
+### `domain/SearchIndexerTest.kt` (14)
+
+- a walk records every file under the volume
+- a hidden folder is skipped whole when the preference says so
+- rows are handed over in batches rather than one write per file
+- progress is reported before the first batch is written
+- a catch-up reads names and the metadata of nothing it already knows
+- a catch-up finds a file that arrived and leaves the rest alone
+- a catch-up drops a file that has gone
+- a catch-up takes a folder that has gone with its whole subtree
+- a folder that cannot be read is left exactly as it was
+- a catch-up records that it was a catch-up and not a walk
+- a catch-up with the other hidden-files preference forgets what it should not show
+- a walk and a catch-up over a real tree keep the index level with it
+- the timestamp is stamped from the clock when the pass finishes
+- a second pass replaces the first rather than doubling it
+
+### `domain/TrashTest.kt` (19)
+
+- staging moves an item aside and records where it came from
+- restoring puts an item back exactly where it was
+- a folder comes back whole
+- two items sharing a name stage without one overwriting the other
+- restoring never overwrites a name that was taken again meanwhile
+- expiry drops only the batches that are old enough
+- staging nothing yields no undo handle
+- restoring a batch that is already gone is reported, not thrown
+- a batch folder is recognised by its name, and nothing else is
+- the trash folder is hidden, the way Android hides dotfiles
+- staging records where the item came from
+- a staged delete is still recoverable after the process forgets everything
+- the index is emptied once a restore has succeeded
+- a row whose file has gone is not offered back
+- purging forgets the rows it deletes
+- purging drops rows whose folder has gone, so the index cannot grow forever
+- what is offered back carries the name, the type, the size and where it came from
+- one item can be put back without disturbing the rest of its batch
+- a batch is forgotten once its last item is back
+
+### `domain/UsageAnalysisTest.kt` (6)
+
+- folder sizes are recursive and sorted largest first
+- rows are reported as they are measured
+- the largest files are found anywhere below the folder
+- the total rolls up the whole tree
+- an empty folder measures zero
+- a folder that is gone measures nothing rather than blowing up
+
+### `service/FileOpServiceTest.kt` (4)
+
+- the channel is created once and reused
+- an indeterminate operation says it is preparing
+- a determinate operation shows the counter
+- starting the service puts it in the foreground with that title
+
+### `ui/browser/BrowserViewModelTest.kt` (82)
+
+- starts on the root list
+- opening a folder lists it with directories first and natural order
+- hidden files are filtered out until asked for
+- search narrows the listing
+- sorting by size reorders the listing
+- navigating up goes to the parent then to the roots
+- selection survives toggling and clears on navigation
+- a file with no preview of its own is handed to the system
+- an image still opens in the built-in viewer, but can also be handed on
+- the SAF fallback will not hand out a file, and says why
+- a zip opens its contents instead of unpacking them
+- an archive that cannot be read closes again and says so
+- documents and media open in their own viewers
+- markup is drawn, and the text editor stays one action away
+- an apk is inspected rather than being handed to another app
+- a text entry inside an archive is previewed without writing anything
+- markup inside an archive is rendered, and not offered for editing
+- an image entry is copied into the scratch space, and the copy goes with the preview
+- a preview that would be too large is refused before anything is read
+- an entry with no viewer is offered an extraction instead
+- closing a preview of an entry returns to the archive it came from
+- searching everywhere answers from the index, across folders
+- switching scope re-answers the search rather than leaving the old results
+- the index is rebuilt when it has never been built
+- a fresh index is neither walked nor caught up
+- a stale index is caught up, and the answer includes what the catch-up found
+- a changed hidden-files preference asks for a walk rather than a catch-up
+- a search from the volume list is answered everywhere, with no folder open
+- opening a library lists the index's files of that kind and no folders
+- a library brings the index up to date behind the answer
+- a library row whose file has gone is dropped and forgotten
+- returning to the app keeps the library open rather than dropping to the roots
+- the home counts come from the index, and there are none without one
+- finding duplicates compares the index's candidates and reports what matched
+- a scan with nothing to compare says so rather than reporting a clean device
+- deleting the ticked copies closes the scan rather than patching it
+- ticking a copy does not tick every copy
+- a search from inside a library is answered everywhere, because a library is not a folder
+- a preview that needs a real file is refused in the SAF fallback
+- createFolder makes a directory in the current folder
+- createFolder refuses a name that is a path
+- rename moves the entry to the new name
+- delete removes the selected entries directly when the platform has no dialog
+- delete prefers the platform dialog and leaves the files alone until it returns
+- copy then paste duplicates into the current folder
+- cut then paste moves and clears the clipboard
+- a pasted name that already exists asks before overwriting
+- without any grant the browser asks for a folder
+- a granted tree becomes the only root
+- focusing a folder in the master pane lists it for the detail pane
+- focusing a file keeps the entry and previews no children
+- the detail pane honours the hidden-files preference
+- navigating clears the focus so the pane cannot show a stale folder
+- a focused volume previews the contents of that volume
+- the browsed folder and view settings survive a restart
+- a search query is not restored, so the folder is not hidden on launch
+- a selection and a clipboard do not survive a restart
+- restarting on the roots screen stays on the roots screen
+- search reaches into a subfolder, not just the one being browsed
+- clearing the query brings the folder back
+- navigating abandons a search, because its results belonged to the old folder
+- the search honours the hidden-files preference
+- a delete only moves the item aside, so it can be taken back
+- undoing a delete puts the file back
+- batch rename numbers the files in the order they are listed
+- batch rename continues a series the folder already has
+- a batch rename is a single undo step
+- batch rename refuses a batch whose names are already taken
+- a filename cannot be a path
+- a batch rename cannot be given a path either
+- undoing a rename puts the old name back
+- undoing a cut-paste moves the item back
+- a copy is not undoable, because nothing was changed
+- the undo handle is cleared once it has been used
+- a shared file is saved into the folder the user chose
+- a shared file never overwrites one already in the folder
+- a file the sender cannot name still lands under a usable one
+- a share that cannot be read leaves nothing behind
+- cancelling a share leaves an ordinary browser
+- with no reader wired up, a share is not silently pretended
+- while providing, tapping a file chooses it instead of opening it
+- while providing, a folder still navigates so the file can actually be reached
+
+### Test files with no source file of the same name
+
+- `ProjectInvariantsTest.kt` (4 tests) - ProjectInvariants, ProjectInvariants
+
+---
+
+Purpose taken from the first KDoc rather than from a declaration sharing the file's name (15 of 74): `MainActivity.kt`, `ArchiveFormats.kt`, `FileNames.kt`, `ApkInspector.kt`, `OperationModels.kt`, `TextRes.kt`, `BrowserComponents.kt`, `BrowserDetailPane.kt`, `BrowserDialogs.kt`, `BrowserRoots.kt`, `BrowserScreen.kt`, `EntryIntents.kt`, `Color.kt`, `Theme.kt`, `Type.kt`
diff --git a/tools/make-code-map.py b/tools/make-code-map.py
new file mode 100644
index 0000000..5b30b07
--- /dev/null
+++ b/tools/make-code-map.py
@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+"""
+Builds docs/CODE_MAP.md: the one-file index of what lives where.
+
+ python3 tools/make-code-map.py # write docs/CODE_MAP.md
+ python3 tools/make-code-map.py --check # exit 1 if the file on disk is stale
+
+Why this exists: the source is 12 000 lines across 74 files, which is more than fits in
+anyone's head - and the first question about any change is "which file, and what does it
+do?". A hand-written overview of that would be out of date within a milestone, so this one
+is *derived*: every line comes from a declaration or from the KDoc already next to it, and
+`--check` fails the moment the file stops matching the tree.
+
+The rule that makes the map useful rather than merely long: a file is described by the KDoc
+of the declaration that shares its name (`BatchRename.kt` -> `BatchRename`), which is the
+convention this codebase already follows. When no declaration matches, the first KDoc in the
+file is used, and the file is listed at the end so the convention can be checked rather than
+assumed.
+
+Test names are sentences about behaviour in this project, so the behaviour index at the end
+doubles as the specification: every name is a claim about what the app does.
+"""
+import argparse
+import re
+import sys
+from pathlib import Path
+
+PACKAGE = "de/softwareapp_hb/filemanager"
+
+# One line per package, because a directory has no comment of its own. Kept here rather than
+# in the generated file so there is exactly one place to update it; the README's layout
+# section says the same thing more briefly.
+PACKAGES = {
+ "core": "Pure Kotlin, **no Android imports**: paths, file names, kinds, sorting, "
+ "formatting, matching, the batch-rename planner",
+ "data": "Storage backends (all-files access and SAF), volumes, incoming shares, the "
+ "SQLite search index and trash index",
+ "domain": "Operations over a source: copy/move/delete/rename, archives, search walking, "
+ "duplicate finding, trash, usage analysis",
+ "service": "The foreground service that keeps a long operation alive and reports it",
+ "text": "Domain enum to string-resource mapping",
+ "ui/browser": "The browser screen: view model, state, list and grid, dialogs, selection bar",
+ "ui/viewer": "The built-in viewers (text, image, PDF, media, markup, archive contents)",
+ "ui/usage": "The storage-usage screen",
+ "ui/trash": "The trash screen",
+ "ui/duplicates": "The duplicate-finder screen",
+ "ui/theme": "Compose theme, colours, type",
+ "ui": "Compose screens and their shared pieces",
+ "": "Application entry points",
+}
+
+DECLARATION = re.compile(
+ r"^(?:(?:public|internal|private|protected|abstract|open|sealed|data|value|inline|"
+ r"suspend|operator|override|const|lateinit|external|annotation|enum)\s+)*"
+ r"(enum class|sealed class|sealed interface|data class|data object|annotation class|"
+ r"value class|class|object|interface|fun|val|var|typealias)\s+([A-Za-z_][A-Za-z0-9_]*)"
+)
+DOC_START = re.compile(r"^\s*/\*\*")
+TEST_FUN = re.compile(r"^\s*fun\s+`([^`]+)`")
+TEST_CLASS = re.compile(r"^(?:class|object)\s+([A-Za-z_][A-Za-z0-9_]*)")
+
+
+def repo_root() -> Path:
+ """The repository root, found from this file rather than from the working directory."""
+ here = Path(__file__).resolve()
+ for candidate in (here.parent, *here.parents):
+ if (candidate / "settings.gradle.kts").is_file():
+ return candidate
+ sys.exit("make-code-map.py: no settings.gradle.kts above %s" % here)
+
+
+def first_sentence(block: str, limit: int = 190) -> str:
+ """The first sentence of a KDoc block, as one line and without markdown decoration."""
+ parts = []
+ for line in block.splitlines():
+ line = line.strip().lstrip("/").lstrip("*").strip()
+ if line.startswith("@") or not line:
+ continue
+ parts.append(line)
+ joined = " ".join(parts)
+ joined = re.sub(r"\[([^\]]+)]", r"\1", joined)
+ joined = re.sub(r"`([^`]*)`", r"\1", joined)
+ joined = re.sub(r"\*+", "", joined)
+ match = re.search(r"^(.*?[.!?])(\s|$)", joined)
+ sentence = (match.group(1) if match else joined).strip()
+ if len(sentence) > limit:
+ sentence = sentence[:limit].rsplit(" ", 1)[0] + "…"
+ return sentence
+
+
+def kdocs(source: str):
+ """Every KDoc block: (start line, body, line of the declaration it documents)."""
+ lines = source.splitlines()
+ blocks = []
+ index = 0
+ while index < len(lines):
+ if DOC_START.match(lines[index]):
+ end = index
+ body = []
+ while end < len(lines):
+ body.append(lines[end])
+ if "*/" in lines[end]:
+ break
+ end += 1
+ after = end + 1
+ while after < len(lines) and (not lines[after].strip()
+ or lines[after].lstrip().startswith("@")):
+ after += 1
+ blocks.append((index, "\n".join(body), after))
+ index = end + 1
+ else:
+ index += 1
+ return blocks
+
+
+def declarations(source: str):
+ """Top-level declarations, as (kind, name, doc first sentence, line number)."""
+ lines = source.splitlines()
+ blocks = kdocs(source)
+ doc_of = {after: first_sentence(body) for start, body, after in blocks}
+ found = []
+ for index, line in enumerate(lines):
+ if not line or line[0].isspace():
+ continue
+ match = DECLARATION.match(line)
+ if not match:
+ continue
+ found.append((match.group(1), match.group(2), doc_of.get(index), index + 1))
+ return found
+
+
+def purpose_of(path: Path, decls):
+ """The KDoc of the declaration sharing the file's name, else the first one there is."""
+ for kind, name, doc, line in decls:
+ if name == path.stem and doc:
+ return doc, True
+ for kind, name, doc, line in decls:
+ if doc:
+ return doc, False
+ return "", False
+
+
+def scan_source(main_dir: Path):
+ entries = []
+ for path in sorted(main_dir.rglob("*.kt")):
+ source = path.read_text(encoding="utf-8")
+ decls = declarations(source)
+ doc, matched = purpose_of(path, decls)
+ entries.append({
+ "rel": path.relative_to(main_dir).as_posix(),
+ "package": path.parent.relative_to(main_dir).as_posix(),
+ "stem": path.stem,
+ "lines": len(source.splitlines()),
+ "decls": decls,
+ "doc": doc,
+ "matched": matched,
+ })
+ return entries
+
+
+def scan_tests(test_dir: Path):
+ entries = []
+ for path in sorted(test_dir.rglob("*.kt")):
+ source = path.read_text(encoding="utf-8")
+ lines = source.splitlines()
+ names = [TEST_FUN.match(line).group(1) for line in lines if TEST_FUN.match(line)]
+ if not names:
+ continue
+ subjects = [match.group(1)[:-4]
+ for match in (TEST_CLASS.match(line) for line in lines)
+ if match and match.group(1).endswith("Test")]
+ # A test class is the usual way to say what is covered; a test file named after the file
+ # rather than after a declaration (FileNamesTest -> FileNames.kt) is the other, so both
+ # are offered and the source tree decides which one lands.
+ subjects.append(path.stem[:-4] if path.stem.endswith("Test") else path.stem)
+ entries.append({
+ "rel": path.relative_to(test_dir).as_posix(),
+ "stem": path.stem,
+ "subjects": subjects,
+ "tests": names,
+ })
+ return entries
+
+
+def render(root: Path) -> str:
+ main_dir = root / "app/src/main/java" / PACKAGE
+ test_dir = root / "app/src/test/java" / PACKAGE
+ sources = scan_source(main_dir)
+ tests = scan_tests(test_dir)
+
+ by_declaration = {}
+ by_stem = {}
+ for entry in sources:
+ for kind, name, doc, line in entry["decls"]:
+ by_declaration.setdefault(name, entry)
+ by_stem.setdefault(entry["stem"], entry)
+
+ tests_for = {}
+ unplaced = []
+ for test in tests:
+ placed = False
+ for subject in test["subjects"]:
+ target = by_declaration.get(subject) or by_stem.get(subject)
+ if target is None:
+ continue
+ bucket = tests_for.setdefault(target["rel"], [])
+ if test not in bucket:
+ bucket.append(test)
+ placed = True
+ if not placed:
+ unplaced.append(test)
+
+ out = [
+ "# Code map\n",
+ "Generated from the source by `tools/make-code-map.py` - do not edit by hand. "
+ "Regenerate it after a change (`python3 tools/make-code-map.py`); `--check` exits "
+ "non-zero when this file is stale.\n",
+ "%d source files, %d lines - %d test files, %d tests. The behaviour index at the end "
+ "lists every test name, and in this project a test name is a sentence about what the "
+ "app does, so it doubles as the specification.\n"
+ % (len(sources), sum(entry["lines"] for entry in sources),
+ len(tests), sum(len(test["tests"]) for test in tests)),
+ "Start at `AGENTS.md`. What is done, and why: `docs/ROADMAP.md` and `CHANGELOG.md`. "
+ "What was verified by hand rather than by a test: `docs/EDGE_CASE_TESTING.md`.\n",
+ ]
+
+ packages = list(PACKAGES) + [name for name in sorted({entry["package"] for entry in sources})
+ if name not in PACKAGES]
+ for package in packages:
+ entries = [entry for entry in sources if entry["package"] == package]
+ if not entries:
+ continue
+ out.append("\n## %s\n" % (package + "/" if package else "(root files)"))
+ if PACKAGES.get(package):
+ out.append("%s\n" % PACKAGES[package])
+ out.append("\n| file | lines | what it holds | tests |")
+ out.append("|---|---|---|---|")
+ for entry in entries:
+ declared = ", ".join(name + "()" if kind == "fun" else name
+ for kind, name, doc, line in entry["decls"])
+ cell = "%s%s" % ("%s " % entry["doc"] if entry["doc"] else "", declared)
+ covered = ", ".join("`%s` (%d)" % (test["stem"], len(test["tests"]))
+ for test in tests_for.get(entry["rel"], [])) or "-"
+ out.append("| `%s` | %d | %s | %s |"
+ % (Path(entry["rel"]).name, entry["lines"],
+ cell.replace("|", "\\|").strip(), covered))
+
+ out.append("\n## Behaviour index\n")
+ out.append("Every test name in the suite, by file: the fastest way to see whether "
+ "something is already covered, and what the app claims to do.\n")
+ for test in tests:
+ out.append("\n### `%s` (%d)\n" % (test["rel"], len(test["tests"])))
+ out.extend("- %s" % name for name in test["tests"])
+ if unplaced:
+ out.append("\n### Test files with no source file of the same name\n")
+ for test in unplaced:
+ out.append("- `%s` (%d tests) - %s"
+ % (test["rel"], len(test["tests"]),
+ ", ".join(test["subjects"]) or "no named subject"))
+
+ fallbacks = [entry for entry in sources if not entry["matched"]]
+ out.append("\n---\n")
+ out.append("Purpose taken from the first KDoc rather than from a declaration sharing the "
+ "file's name (%d of %d): %s\n"
+ % (len(fallbacks), len(sources),
+ ", ".join("`%s`" % Path(entry["rel"]).name for entry in fallbacks) or "none"))
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(out)).rstrip("\n") + "\n"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Build or verify docs/CODE_MAP.md")
+ parser.add_argument("--check", action="store_true",
+ help="exit non-zero if docs/CODE_MAP.md is out of date")
+ args = parser.parse_args()
+
+ root = repo_root()
+ target = root / "docs/CODE_MAP.md"
+ rendered = render(root)
+
+ if args.check:
+ current = target.read_text(encoding="utf-8") if target.is_file() else ""
+ if current == rendered:
+ print("docs/CODE_MAP.md is current")
+ return 0
+ current_lines = current.splitlines()
+ rendered_lines = rendered.splitlines()
+ for number in range(max(len(current_lines), len(rendered_lines))):
+ old = current_lines[number] if number < len(current_lines) else ""
+ new = rendered_lines[number] if number < len(rendered_lines) else ""
+ if old != new:
+ print("docs/CODE_MAP.md is out of date at line %d:" % (number + 1))
+ print(" on disk: %s" % old)
+ print(" source: %s" % new)
+ break
+ print("run: python3 tools/make-code-map.py")
+ return 1
+
+ target.write_text(rendered, encoding="utf-8")
+ print("wrote %s (%d lines)" % (target.relative_to(root), rendered.count("\n") + 1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+