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()) + +