b1bc805af0
Everything this repository promised was voluntary until now: 371 tests, lint and the generated code map ran only if whoever changed the code remembered to run them. tools/verify.sh is that gate as one command - the map check, detekt, the unit tests and lint, cheapest first so a stale document fails in a second rather than after a release build - and --release adds the R8 build. The ordering is not cosmetic: the two cheapest checks are also the two that catch drift nothing else can see. The same script runs in three places: CI on every push and pull request, a nightly --release job (R8 is the one check nothing else performs, and it has hidden a real defect here once), and a pre-push hook once the clone has been told to use .githooks. The nightly also uploads the R8 mapping, which is what turns a release stack trace back into a line of Kotlin and is only worth keeping if it belongs to the build that shipped. Detekt is adopted with a baseline, which is the entire adoption strategy: everything it finds today is frozen in config/detekt/baseline.xml, so the gate fails on a new finding rather than on a backlog nobody is going to clear this week. Entries are keyed by rule, file and enclosing declaration, so a new long function is still reported inside a file that already has one - proved by writing a TODO into a file, watching the build fail on that exact line, and taking it out again. What it found first agrees with the churn numbers from the last pass: ArchiveEngine 16 findings, BrowserViewModel 11, SelectionBar 9, 86 in total over 13 rules, the largest groups being the complexity ones this was added for (cyclomatic complexity 10, nested block depth 7, too many functions 4, long methods 3, two classes over 700 lines) plus 30 over-long lines. Three findings were fixed rather than parked, because they were real: dead `menuOpen` state left in BrowserScreen when the overflow menu moved into BrowserTopBar, a missing trailing newline, and two `db` parameters that are the platform's signature rather than an oversight (suppressed with that reason written next to them). Four rules were configured rather than obeyed: PackageNaming, because the applicationId's underscore is an identity this app cannot change; FunctionNaming, for @Composable (PascalCase by convention) and for backticked test names, which is the project's specification style; MagicNumber, because sizes and limits are named constants here; and MatchingDeclarationName, because a file is named after its concept rather than after a declaration inside it. Two tooling facts cost real time and are in neither tool's documentation. Detekt runs through its CLI rather than its Gradle plugin: the plugin performs the analysis inside the build's own JVM, and detekt 1.23 cannot parse JDK 25's version string - it fails with "25.0.4.1" as the entire error message - so the task drives detekt-cli on a 21 launcher, which is also the only way to pin the JVM the analyser runs on. And a baseline has to exist before it can be read, which is why it starts as an empty document rather than being generated into place. Verified: the fast gate is green in 17 seconds, --release adds a 36-second R8 build, 371 JVM tests still pass, lint is clean and the map is current. CHANGELOG, README, AGENTS.md, ROADMAP (the gate is now a recorded decision) and the regenerated map carry it.
794 lines
63 KiB
Markdown
794 lines
63 KiB
Markdown
# Changelog
|
||
|
||
All notable changes to File Manager are documented in this file.
|
||
|
||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
|
||
project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||
|
||
`versionCode` and `versionName` are defined in `app/build.gradle.kts`. Each Play
|
||
upload needs a **higher `versionCode`** than every previous upload.
|
||
|
||
## [Unreleased]
|
||
|
||
### Added
|
||
|
||
- **Several files can be renamed at once, numbered in a series that carries on.** Selecting more
|
||
than one file now offers Rename in the action bar, which opens a **pattern** dialog instead of the
|
||
single-file one: a name for the batch, the number to start at, and a live preview of the names it
|
||
will produce — *Will be renamed to Holiday (1).jpg, Holiday (2).jpg, Holiday (3).jpg* — because a
|
||
batch is the one rename whose result cannot be read off its input. **The number is the point.** A
|
||
batch that always starts at 1 is useless the second time it is used, so `core/BatchRename` reads
|
||
the names already in the folder and starts at the highest one it finds: `series (1)` and
|
||
`series (2)` from an earlier batch make the next one start at 3, which is exactly the case the
|
||
market survey's most-helpful complaint describes ("if I later find another group of photos which I
|
||
want to continue the batch naming with 'Ice (3)…'"). Three rules are written in the source as
|
||
decisions rather than defaults, because each one changes what a user ends up with: the extension is
|
||
kept **per file** (a mixed batch of `.jpg` and `.png` keeps each file's own, since a pattern names
|
||
the file and not its type), the series is counted **across extensions** (`Holiday (9).png` makes
|
||
the next number 10 for a batch of `.jpg`s — counting per extension would put `Holiday (10).jpg` and
|
||
`Holiday (1).png` in one folder while calling them the same series), and it continues from the
|
||
**highest** number rather than from the first gap, because someone renaming a second batch is
|
||
carrying a series on rather than filling in the holes of one; where a gap matters more, the start
|
||
number is theirs to type.
|
||
|
||
A batch is refused as a whole rather than performed until it hits the problem, because it cannot
|
||
be *partly* right: nine files renamed of twelve would leave the user to work out which nine, and
|
||
to guess whether the last three crashed or were skipped. So a target the folder already holds
|
||
blocks the batch and is **named** as it is typed (*series (1).txt is already in this folder*), with
|
||
the Rename button disabled; a selection whose files already hold the names asked for says so
|
||
instead of doing nothing; and a *chain* is declined outright — `a.txt` taking `a (1).txt` while
|
||
`a (1).txt` is itself in the batch would need the files shuffled through temporary names, and
|
||
refusing plainly beats doing it half way. Every rename in the batch goes through the same
|
||
`FileOperations.rename` a single rename uses, so nothing is ever overwritten, and the successful
|
||
ones are collected into the **one** `Undo.Relocate` step the single rename already used.
|
||
|
||
Found while writing it, and fixed with it: **a rename could be a path.** `rename(path,
|
||
"notes/final.txt")` built a child path that resolved *into* a subfolder, so a name containing a
|
||
separator quietly moved the file instead of renaming it — with nothing in the dialog or the listing
|
||
to show for it. Both the single rename and the batch now refuse a name that is not one path segment
|
||
(`core/FileNames.isUsableName`), and the dialog says which rule it is. **20 new core tests**
|
||
(`BatchRenameTest`, `FileNamesTest`) and 6 through the view model, **350 JVM tests** in total.
|
||
Verified by driving it on the Xiaomi phone against a folder that already held `series (1).txt` and
|
||
`series (2).txt`: with three files selected and `series` typed in, **Start at** moved to 3 on its
|
||
own and the preview listed `series (3).txt, series (4).txt, series (5).txt`; afterwards the folder
|
||
held all five, each file's contents having travelled with it, the two originals untouched — and the
|
||
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.
|
||
|
||
- **Folders can be starred, and the home screen opens with your own list.** The app bar of any folder
|
||
now carries a star; a filled one means it is already in **Favourites**, which sits above the
|
||
libraries on the home screen — above, because it is the user's own list, and absent entirely while
|
||
it is empty, so a first-time user still meets the libraries first. The star in the row *is* the
|
||
removal, which is what a star means everywhere else: the mark that says "this is a favourite" is
|
||
the thing you tap to stop it being one.
|
||
|
||
A favourite is a **reference, not a copy**: one path per row in a small SQLite table
|
||
(`data/BookmarkStore` and `SqliteBookmarkStore`, in their own database rather than as a table in
|
||
the trash index, because the two have nothing to say to each other), so reading the whole list is
|
||
one query and no filesystem work. That is what makes it affordable on every visit to the home
|
||
screen, and it is also why **a folder that has gone is kept and marked rather than deleted**: a
|
||
card out of its slot and a folder deleted in another app look exactly alike from here, and
|
||
silently forgetting someone's list because their SD card was out is the worse of the two
|
||
mistakes. The row then says *Folder not found*, and opening it re-checks at that moment — the flag
|
||
is as old as the screen — so the answer is a message rather than an empty listing, which would
|
||
read as "this folder is empty" instead of "this folder is gone".
|
||
|
||
Two smaller decisions came out of the same question. **No star in a library or on the root
|
||
screen**: neither is a folder, so there is nothing to come back to — the distinction that governs
|
||
the new-folder button — and the view model refuses the toggle there too rather than trusting a
|
||
stale screen. And **no undo for a bookmark**: `Undo` is "the last change to the filesystem the app
|
||
made", which a favourite is not, and one tap on the star is already the way back. **16 tests** (9
|
||
against real SQLite through Robolectric — round trip, ordering, "starring twice neither
|
||
duplicates nor moves it", and the same no-drop migration guard the trash index carries; 7 through
|
||
the view model — toggling, outliving the view model that added it, newest first, the missing
|
||
folder kept and marked, the missing folder refusing to open, and a library refusing to be
|
||
starred). **371 JVM tests**, lint clean.
|
||
|
||
Verified on the phone: the star appears only inside a folder; a starred volume reads as "Internal
|
||
storage" rather than as `0`, because the label comes from the same place the breadcrumbs do; the
|
||
home screen lists it above the libraries; the row opens it with the star already filled; and
|
||
removing it from the row takes the whole section away with a snackbar.
|
||
|
||
- **One command is now the gate, and Kotlin static analysis is part of it.** `tools/verify.sh` runs the
|
||
code-map check, detekt, the unit tests and lint — cheapest first, so a stale document fails in a
|
||
second rather than after a release build — with `--release` adding the R8 build. The same script
|
||
runs in `.github/workflows/verify.yml` on every push and pull request, nightly with `--release`, and
|
||
in a pre-push hook once `git config core.hooksPath .githooks` has been run. Until now every
|
||
guarantee was voluntary: 371 tests, lint and the map ran only if someone remembered, which was the
|
||
largest maintenance risk this repository had.
|
||
|
||
**Detekt is adopted with a baseline, and that is the whole point**: everything it finds today is
|
||
frozen in `config/detekt/baseline.xml`, so the gate fails on a *new* finding rather than on a
|
||
backlog nobody is going to clear this week. Entries are keyed by rule, file and enclosing
|
||
declaration, so a new long function is still reported inside a file that already has one — proved
|
||
by writing a `TODO` into a file, watching the build fail on that line, and removing it. What it
|
||
found first is what the churn numbers had already said: `ArchiveEngine` 16 findings,
|
||
`BrowserViewModel` 11, `SelectionBar` 9 — 86 over 13 rules, the largest groups being the complexity
|
||
ones this was added for (cyclomatic complexity 10, nested depth 7, too many functions 4, long
|
||
methods 3, two classes over 700 lines) and 30 over-long lines. Three findings were fixed rather
|
||
than parked: dead `menuOpen` state in `BrowserScreen`, a missing trailing newline, and two `db`
|
||
parameters that are the platform's signature rather than an oversight (suppressed, with the
|
||
reason). Four rules were configured rather than obeyed: `PackageNaming`, because the
|
||
applicationId's underscore is an identity this app cannot change; `FunctionNaming` for `@Composable`
|
||
(PascalCase by convention) and for backticked test names; `MagicNumber`, because sizes and limits
|
||
are named constants here; and `MatchingDeclarationName`, because a file is named after its concept.
|
||
|
||
Two tooling facts are recorded because they cost time and are in neither tool's documentation.
|
||
Detekt runs through its **CLI** rather than its Gradle plugin: the plugin performs the analysis
|
||
inside the build's own JVM, and detekt 1.23 cannot parse JDK 25's version string — it fails with
|
||
`25.0.4.1` as the entire error message — so `:app:detekt` drives `detekt-cli` on a 21 launcher,
|
||
which is also the only way to pin the JVM the analyser runs on. And a baseline has to *exist*
|
||
before it can be read, which is why it starts as an empty document instead of being generated into
|
||
place. **371 JVM tests** still pass, lint is clean, the map is current, and the gate is green in
|
||
17 seconds.
|
||
|
||
### Changed
|
||
|
||
- **File thumbnails are bigger.** The leading visual in a list row went from 28 dp to 48 dp and
|
||
the one in a grid cell from 40 dp to 96 dp, with the grid's minimum column width raised from
|
||
108 dp to 144 dp so the larger tile and the two-line name under it still fit. Neither size was
|
||
ever clipped, but both were small enough that an image read as a coloured square rather than
|
||
as something recognisable, which is the one thing a thumbnail is for. `ListItem` measures its
|
||
leading slot instead of capping it — checked against the Material 3 source, where the slot is
|
||
measured with loose constraints and its width is only subtracted from what the name gets — so
|
||
a row simply grows to suit the image rather than cropping it. Not covered by the JVM suite:
|
||
no test in this project renders Compose, so a dp value has nothing to fail against. The change
|
||
is held together instead by the build compiling and by the three sizes living in one place,
|
||
`ListThumbnailSize` / `GridThumbnailSize` / `GridCellMinSize` in `BrowserComponents`.
|
||
|
||
### Fixed
|
||
|
||
- **The search field told the truth everywhere except inside a library.** Opening the field from a
|
||
library left the scope on "this folder" — the hint read *Search this folder* and a folder toggle
|
||
sat beside it — while the search actually ran everywhere, because the view model has always known
|
||
that a library is not a folder and answered from the index instead. So the screen claimed a scope it
|
||
was not using, and offered a toggle whose two settings did the same thing. Switching on open now
|
||
asks whether there is a **path** rather than whether this is the root list — a library has none
|
||
either — and the toggle is shown only when there is a folder to choose about, because a control
|
||
that cannot change the outcome implies a choice the user does not have. Found by driving the app on
|
||
the phone rather than by reading it: the search itself was right, and it was the screen that was
|
||
wrong.
|
||
|
||
- **Searching needed a volume to be open first, and needed a second tap to be typed into.** Two
|
||
papercuts in the feature added above, both found by using it. `BrowserContent` decided between
|
||
the volume list and a folder listing on `path == null` alone, so on the "Storage" screen the
|
||
roots were drawn *over* the answer: a search everywhere from there was computed and never
|
||
shown. The switch now also asks whether a search is running — its results are full paths, so
|
||
nothing has to be open first — and the volume list is drawn only when there is neither a folder
|
||
nor a search. Opening the field there also switches the scope to everywhere, because "this
|
||
folder" has no meaning with no folder open and the field's own hint would otherwise invite a
|
||
query that could never match; the view model makes the same fallback on its own, so a query
|
||
typed from the volume list is answered everywhere whether or not the scope was switched. And
|
||
tapping the search action now focuses the field and brings the keyboard up with it: it waited
|
||
for the frame the field appears in, because focusing a node that is not attached yet does
|
||
nothing, and asking the user to tap the box they just opened was one tap of pure ceremony.
|
||
|
||
- **The progress notification never appeared on Android 13 or newer, and five permissions were
|
||
declared that the app never used.** `StorageAccessPolicy.missingPermissions` returned an empty
|
||
list from API 33 on the reasoning that "all files access" is a special permission rather than
|
||
a runtime one, so browsing needs no runtime grant. That part is true — but the early return
|
||
also silently dropped `POST_NOTIFICATIONS` from the set, and nothing else in the app ever
|
||
requested it. On Android 13+ the grant could therefore never be given at all: `startForeground`
|
||
still ran, so nothing crashed and every test passed, while the system dropped the notification
|
||
(`importance=NONE`) and even Settings could not turn it on, because Android will not let a user
|
||
grant a permission the app has never asked for. The four `READ_MEDIA_*` declarations were in
|
||
the same position — declared for "thumbnails and the built-in viewers on Android 13+", never
|
||
requested, and never needed: Coil loads thumbnails and the viewers from a real filesystem path,
|
||
and the only `MediaStore` use is the best-effort delete request, which "all files access" already
|
||
covers. Those declarations are removed, and the notification grant is now requested alongside
|
||
the storage grant — the moment the user commits to the app's full mode, and the only moment
|
||
Android accepts. Verified on an Android 15 emulator: the dialog now appears, the app's
|
||
notification importance goes `NONE → DEFAULT`, and the progress notification is posted, kept up
|
||
to date and torn down around a real 220 MB copy.
|
||
|
||
- **The selection bar drew its last three actions off the screen on a phone, where nothing could
|
||
reach them.** With a single archive selected the bar holds ten `IconButton`s — Copy, Cut, Rename,
|
||
Details, Share, Delete, Compress, Extract, Select all and Clear selection — and at the 48 dp
|
||
minimum touch size that needs roughly 480 dp plus the item-count label, against the 411 dp a
|
||
normal phone has. `BottomAppBar` is a plain `Row` and does not scroll, so Compose laid the
|
||
surplus out past the right edge and clipped it: measured on a 1080 px (411 dp) screen the row
|
||
ran Copy(167)…Compress(923–980, itself squeezed from 63 px to 57) and then simply stopped, while
|
||
the same selection in a 2400 px landscape window showed all ten. `Extract here`, `Select all`
|
||
and `Clear selection` were therefore unreachable on the form factor most people use, which made
|
||
the app's own archive feature look like it did not exist — and the extract path itself was fine,
|
||
`ArchiveFormats.isExtractable` had judged the `.zip` correctly all along. The action row now
|
||
scrolls, so nothing can be clipped at any width or font scale. Found by actually driving compress
|
||
and extract from the UI on the API 35 emulator instead of trusting the scripted long-press
|
||
gesture that had already proved flaky; the note that this path "is still worth a manual pass"
|
||
turned out to be exactly right.
|
||
|
||
- **`.tar.bz2` and `.tar.xz` did not unpack, unlike `.tar.gz`.** `ArchiveFormats.formatOf`
|
||
matched `.tar.gz`/`.tgz` as a combined format but let `.tar.bz2` and `.tar.xz` fall through to
|
||
the bare `.bz2` and `.xz` wrappers, so extracting one decompressed a single layer and left the
|
||
user holding a `.tar` to extract again — while the same archive named `.tar.gz` unpacked in one
|
||
step. The store listing advertised "extract ZIP, TAR, TAR.GZ, GZ, BZ2, XZ and 7z", which reads
|
||
as though all of them behave alike. Found by extracting one of every supported format through
|
||
the UI on a device instead of trusting the unit tests, which is also how the `.xz` crash was
|
||
found. `TAR_BZIP2` and `TAR_XZ` now sit alongside `TAR_GZIP`, detection consumes the whole tar
|
||
suffix before the bare compressor, and round-trip tests cover all four tar spellings — and the
|
||
suite had a gap there worth naming: neither plain `TAR` nor `TAR_GZIP` had a round-trip test
|
||
either, so the asymmetry had nothing to fail against.
|
||
|
||
- **`.xz` extraction would have crashed on a real device.** Commons Compress declares its
|
||
LZMA/LZMA2 support — the `.xz` compressor and the 7z format — against `org.tukaani:xz` as
|
||
an **optional** dependency. Optional means it is not pulled in transitively, so nothing
|
||
warned at build time, and the app went on advertising `.xz` as extractable. The first user
|
||
to open one would have got `NoClassDefFoundError: org/tukaani/xz/FilterOptions`. The
|
||
library is now declared, and a round-trip test covers the path that was silently missing.
|
||
|
||
- **The storage-usage screen stayed blank for ~45–60 s** on a real 55 GB volume, which
|
||
reads as a hang even though the work was off the main thread. `UsageAnalysis` now passes
|
||
the `FileEntry` down the recursion instead of re-`stat`ing every node — the parent's
|
||
listing already says whether each child is a folder — so the walk costs one listing per
|
||
directory. It also reports each child through an `onProgress` callback, and the ViewModel
|
||
appends and re-sorts as they arrive, so the largest folders appear while the rest of the
|
||
tree is still being measured. Found by running the app on a real phone.
|
||
|
||
- **Misleading first-run copy.** The permission prompt said "Full access was declined" on a
|
||
device where the user had never declined anything. Reworded so it is true whether access
|
||
was declined or simply never granted.
|
||
|
||
- **The file list jumped back to the top whenever a viewer closed.** `BrowserScreen` hands the
|
||
whole screen over to the image viewer, the text editor, the usage view and the trash by
|
||
returning early, which takes the listing out of the composition and, with it, the
|
||
`LazyColumn`'s own scroll state — so exiting an image you had scrolled down to preview put
|
||
you back at the first row, every time. The list and grid states are now remembered by the
|
||
screen, above the point where the overlay takes over, and passed down to the listing. They
|
||
are reset when the *folder* changes and only then, so navigating into a folder still starts
|
||
at the top while coming back from a viewer does not.
|
||
|
||
- **Once a selection was running, tapping a second file opened it instead of selecting it.**
|
||
Long-press started multi-select, but every tap still went to "open", so the only way to add
|
||
another item was a long-press per file — and on a phone that is a gesture most people never
|
||
try. A tap now toggles the item in or out of the selection whenever one is running, which
|
||
is what makes the batch delete the bar offers actually usable; a checkbox on every row (not
|
||
just the selected ones) says so before the attempt. Outside a selection the old behaviour is
|
||
untouched: tap opens on a phone, highlights in the two-pane layout, double tap opens there,
|
||
and double-tap-to-open is suppressed while selecting so it cannot fire instead of the toggle.
|
||
|
||
- **Tapping a file the app could not render did nothing at all.** Video, audio, PDF, DOCX,
|
||
APK, archive — every one of them was silently inert, which made the app look broken and
|
||
made half of what a file manager is for unreachable. `BrowserViewModel.open` had always
|
||
parked those entries in `BrowserUiState.pendingOpen`, and `ui/browser/EntryIntents.kt` had
|
||
`viewIntent()` sitting ready to wrap the path in a `FileProvider` URI and fire
|
||
`ACTION_VIEW` — but nothing ever read the one or called the other. A repository-wide search
|
||
found no call site for either, and a history search showed both were introduced in the same
|
||
commit and never touched again: the wire between them was simply never run. `BrowserScreen`
|
||
now launches the chooser from the same one-shot `LaunchedEffect` pattern its neighbours
|
||
(`pendingDeliver`, `pendingDeleteRequest`) already used, and always clears the signal
|
||
afterwards so a chooser cannot reopen itself on the next recomposition. The codebase's own
|
||
documentation had claimed this worked for some time — `docs/ROADMAP.md` lists "a
|
||
`FileProvider` backs 'open with' and share" under M4, and the changelog said the app
|
||
"already hands files out through the system chooser" — while only *share* was ever wired.
|
||
Nothing caught it because no test touched `pendingOpen`; three do now.
|
||
|
||
- **The details sheet put untranslatable English on screen.** It rendered `FileKind.name`
|
||
directly, so "Type" read `directory`, `archive`, `apk` — enum constants rather than
|
||
labels, and impossible to translate. Every category now has a string resource and a
|
||
`FileKind.labelRes()` mapping alongside the existing `OperationKind.titleRes()`.
|
||
|
||
### Added
|
||
|
||
- **The release checklist now has a step that runs the release build on a device.** The R8 defect
|
||
above was invisible to every check that existed: it passed the whole unit suite, passed lint, and
|
||
worked in the debug APK — the release APK was the only thing that disagreed, and nothing in the
|
||
process had ever run it. [docs/PLAY_RELEASE_CHECKLIST.md](docs/PLAY_RELEASE_CHECKLIST.md) § 4 is
|
||
that step: the install commands (the release build is a separate package, so it can sit beside the
|
||
debug one and be compared directly), the paths most likely to depend on something R8 cannot
|
||
see — thumbnails, the foreground service, the two SQLite databases, each viewer, the
|
||
`FileProvider` — and the one instruction that matters when something differs, which is to assume R8
|
||
removed something rather than that the feature is broken. Section 3 also carries a fresh
|
||
measurement of both artefacts (**AAB 5 181 628 bytes**, **APK 2 294 021 bytes**, both hashed),
|
||
because the previous record predated the library views, the duplicate finder and video thumbnails —
|
||
and a stale hash is worse than no hash, since it reads as if it had been checked.
|
||
|
||
- **A video shows a frame of itself instead of a film-strip icon.** A phone holds more video than
|
||
anything else, and a column of identical icons says nothing about which clip is which. Images and
|
||
videos now take the same path — `EntryIcon` hands Coil the file, and for a video the request asks
|
||
for a frame — which cost exactly one dependency: `io.coil-kt.coil3:coil-video`, at the same version
|
||
as the Coil already in use, whose own POM adds nothing that Coil did not already bring (coil-core,
|
||
kotlin-stdlib, core-ktx). No ExoPlayer and no media3, and **no new permission**: the decoder is the
|
||
platform's own `MediaMetadataRetriever`. The APK audit still shows the same permissions, with no
|
||
`READ_MEDIA_VIDEO` among them.
|
||
|
||
The decoder is registered **explicitly** in `FileManagerApplication`, which is where this nearly
|
||
went wrong. `coil-video` ships a `META-INF/services` entry that registers it on its own, and in a
|
||
debug build that works: both the entry and the class it names are in the APK. In the **release**
|
||
build R8 removes both, because a class mentioned only from a resource looks unused — so the feature
|
||
worked in every build that had been looked at and would have failed silently in the one users
|
||
install. Registering the factory against an `ImageLoader` makes it a code reference R8 cannot drop,
|
||
and the release APK was then re-run on the phone to confirm frames decode there too.
|
||
|
||
Two details are decisions rather than defaults. The frame is taken **one second in**, because the
|
||
first frame of a recording is very often black or the middle of a fade-in, and a thumbnail of a
|
||
black rectangle is no more useful than the icon it replaced. And a decode that **fails** — an
|
||
unsupported codec, or a file only pretending to be a video — settles on the icon rather than
|
||
leaving an empty square, which the row remembers so it is not retried on every recomposition.
|
||
|
||
Verified on the phone, which is the only place this could be verified: two clips made for the
|
||
purpose (`ffmpeg`, a testsrc2 pattern and SMPTE bars) were pushed into the demo folder first, and
|
||
both drew their own pattern as a thumbnail, next to a real 4.3 MB video from the device's own
|
||
storage. The demo library's three stand-in `.mp4`s are zero-filled and cannot decode at all, which
|
||
turned out to be the cheapest possible test of the other half: all three fell back to the icon. The
|
||
same four rows were then re-checked on the **release** APK, which is where the difference shows.
|
||
|
||
- **Libraries: the home screen now opens on what is on the device, not only on where it is.** The
|
||
volume list was the first thing a new user saw, so "where are my photos?" started with knowing
|
||
which folder they were in. The root screen now carries a **Libraries** section — Images, Videos,
|
||
Audio, Documents, Archives and Recent — each row showing how many files the index knows of that
|
||
kind, and each opening an ordinary listing of them: thumbnails, sorting, multi-select, details and
|
||
the same viewers a folder gives. A tap on a photo there opens the image viewer, on a PDF the PDF
|
||
viewer, on an archive its contents. The libraries are actionable, not a report.
|
||
|
||
Three decisions are worth stating. **`kind` is derived, never passed in**: `IndexedFile.kind` is a
|
||
computed property beside `FileEntry.kind`, so one name cannot be classified two ways, and the
|
||
database's copy of it is written from the name purely so a query can filter on it
|
||
(`SearchIndex.byKind` / `recent` / `kindCounts`, SQLite schema **v3**). **The v2 → v3 migration
|
||
rebuilds the files table and deletes the volume stamps with it**: a stamp means "the rows behind
|
||
this are this current", and a rebuilt table has no rows behind it, so keeping the stamp would make
|
||
the next search trust an empty index and skip the walk it still needs. Rebuilding rather than
|
||
backfilling is legitimate *only* because this index is a cache of a walk that can be repeated —
|
||
`SqliteTrashIndex`'s rule is the opposite, and its own documentation says why. **A library obeys
|
||
the index's two rules exactly as the indexed search does**: the answer comes first with the walk
|
||
behind it, and every row is confirmed against the filesystem, with rows whose file has gone
|
||
forgotten rather than displayed. On a device that has never been indexed, the first tap on Images
|
||
therefore starts a walk — which the screen admits to, using the progress row the search already
|
||
had, instead of showing an empty library that reads as an empty device.
|
||
|
||
What is deliberately not in it: **Downloads** and **New files**, which the same competitive survey
|
||
lists, are absent — Downloads is a folder shortcut and New files is a time window with a different
|
||
meaning, and presenting three different things as one feature is how a listing starts lying about
|
||
itself. **Documents includes `.txt`**, because a user looking for their documents means those too,
|
||
even though the app opens them in the text editor. `DIRECTORY`, `APK` and `OTHER` belong to no
|
||
library on purpose, and a test asserts that so the omissions stay decisions. A library is **not
|
||
restored after process death**: the persisted view state holds a path and a library has none, so a
|
||
restart returns to the root screen. **308 JVM tests** (19 added here): grouping and classification
|
||
in `FileCategoryTest`, the three queries and the v2 → v3 migration against real SQLite in
|
||
`SqliteSearchIndexTest`, and the view model in `BrowserViewModelTest` — including that a library is
|
||
not a folder, that the walk happens behind the answer rather than in front of it, and that
|
||
returning to the app re-reads the library instead of dropping the user back to the roots.
|
||
|
||
- **A duplicate finder that decides on the bytes, never on a name or a date.** *Find duplicates* in
|
||
the overflow scans for files holding the same contents, in three passes that each try to read less
|
||
than the last: **size** (free — the index already knows every length, so the candidate list is one
|
||
query and no reading at all), **the first 64 KB of every candidate** (a filter whose only power is
|
||
to rule files *out*), and **the whole file** for whatever survives (the pass that decides). The
|
||
separation is the point: a partial read must never be able to produce an answer, so two files that
|
||
share their first 64 KB and differ afterwards are **not** reported as duplicates. A JVM test builds
|
||
exactly that pair and asserts both files were read in full — it would fail if the cheap pass were
|
||
ever mistaken for the deciding one.
|
||
|
||
The screen shows only what was measured. Groups are ordered by the space that deleting the spare
|
||
copies would give back, each header says how many copies and what they cost ("2 copies · 10.5 MB" /
|
||
"10.5 MB could be freed"), and every row carries the **folder** as well as the size, because a list
|
||
of identical names with no paths is a list nobody can act on. **Nothing is preselected** — which
|
||
copy to keep is the one decision the screen exists to hand over — and the action's label counts
|
||
what it will delete ("Delete 1 copy"), so it cannot be read as "delete the duplicates". A file
|
||
whose bytes cannot be read is **counted and reported** rather than skipped, because "no duplicates"
|
||
from a scan that could not read half its candidates is not the same answer; an empty result says
|
||
*how many* files were compared, or that the index has never run and there was nothing to compare.
|
||
The reading happens on `Dispatchers.IO` with cancellation checked between files **and** between read
|
||
buffers, so closing the screen stops it rather than letting gigabytes of hashing finish behind it.
|
||
|
||
Deleting closes the scan instead of patching it: its rows came from comparing bytes, and quietly
|
||
dropping some of them would leave a list that still looks like a scan while its headers claim
|
||
numbers nobody measured. The delete itself is the app's ordinary one — the trash, the undo
|
||
snackbar, and the platform's own dialog where it applies. **323 JVM tests** (15 added here: 8 for
|
||
the engine, 3 for the candidate query with its floor and cap, 4 for the screen's behaviour).
|
||
Verified on the real phone: four genuine groups found among the device's own files — a ZIP
|
||
duplicated across two folders, a PDF twice, another PDF three times — with the selection label
|
||
reading "Delete 1 copy" and no crash in `logcat -b crash`. The *delete* was deliberately not
|
||
pressed on a device that is not mine to empty; [docs/EDGE_CASE_TESTING.md](docs/EDGE_CASE_TESTING.md)
|
||
records what was and was not exercised.
|
||
|
||
- **A file inside an archive can be read without extracting it, and search can answer for the
|
||
whole volume in one query instead of walking it.** Both were the obvious next question after
|
||
the archive preview and the in-folder search: the preview could say what a zip held but not
|
||
show any of it, and the search could only answer "is it here?" for the tree the user happened
|
||
to be standing in.
|
||
|
||
- **Previewing inside an archive.** `ArchiveEngine` gained a single-entry reader — a ZIP the
|
||
backend can seek jumps straight at the entry's data through the central directory, 7z scans
|
||
its index, a streaming container decompresses past whatever is in front of the entry, and a
|
||
compression wrapper has exactly one entry whose name is derived from the archive's own — and
|
||
`extractEntry` was rewritten on top of it, so there is one implementation of "reach this
|
||
entry" rather than two that could disagree. A tap on a row now *previews* and the row's
|
||
action extracts, which is what a row means everywhere else in this app. Text and markup are
|
||
read into memory, so the commonest question ("what does this readme say?") writes nothing at
|
||
all and works in both storage modes; everything else needs a file a decoder can open, so the
|
||
entry is copied into the app's own cache — one folder per preview, under the entry's own name
|
||
so the viewer's title bar does not read `copy-0.png` — and deleted when the preview closes,
|
||
with a non-recursive delete so a mistake can fail to tidy up but cannot remove a tree. The
|
||
copy is a real path, so image, PDF and media previews work even in the SAF fallback, where
|
||
the *browsed* files cannot be previewed at all. Two limits are stated rather than hidden: a
|
||
known-big entry is refused before anything is read (64 MB), an unknown one is capped while
|
||
decompressing because a 1 MB `.gz` can inflate to a gigabyte, and an entry with no viewer
|
||
says so instead of failing. A copy has no file to save back to, so the text editor shows no
|
||
Save and markup offers no editor; closing a preview returns to the archive it came from
|
||
rather than throwing the user out of the zip. Verified on the API 35 phone: a `.txt` and a
|
||
`.png` inside a real zip, both previewed from the archive list, the copy deleted on close,
|
||
the archive list restored behind it.
|
||
- **Indexed search everywhere.** `SearchIndex` is a small port over SQLite beside the trash
|
||
index, and it inherits that one's doctrine — **an index is not the truth** — in three rules
|
||
that are not optional: a reader drops rows whose file has gone (every hit is checked and the
|
||
misses are forgotten, which is what stops a deleted photo appearing for ever), freshness is
|
||
reported and never assumed, and a volume's rows are replaced wholesale rather than patched,
|
||
so an interrupted pass leaves the previous listing rather than half of this one. Its
|
||
timestamps are written only when a pass *finishes*, so a walk that is cut short by a new
|
||
keystroke reads as older than it is — the safe direction to be wrong in. Names are matched
|
||
with `NameMatcher`'s own semantics translated into `LIKE`: a plain query is a substring, a
|
||
query with `*` or `?` is an anchored glob, and a literal `%` or `_` is escaped, because a
|
||
search that quietly widens what was typed is the sort of thing an index does badly. The
|
||
search row gained a scope toggle, and every search everywhere carries the age of the index
|
||
and a way to rebuild it — a search that silently omits what arrived since the last walk is
|
||
worse than one that spends a minute being sure, so a stale or missing index is rebuilt
|
||
rather than trusted. Rows are written in batches of 500 and the counter reports every 100,
|
||
because a row that says "0 files" for the whole first second reads as nothing happening.
|
||
Deliberately not a foreground service: a pass runs while the screen is up and stops when it
|
||
is not. Verified on the API 35 phone: the first search built the index (a full walk of the
|
||
internal storage), the second one — after an app restart — answered instantly from it and
|
||
found a file in `Download/cline-zip` while browsing `Internal storage`, with its full parent
|
||
path in the row, and a rebuild reported progress and re-answered the query when it finished.
|
||
Two honesty bugs found by watching it: the empty state claimed "Nothing matches" *while* the
|
||
index was still being written, and two progress bars stacked on each other.
|
||
- **…and it no longer re-walks the volume to stay current.** A search everywhere used to
|
||
rebuild the whole index once it was older than fifteen minutes, which meant paying for a walk
|
||
of the entire volume every session — on a phone that is tens of seconds of reading before a
|
||
single result. Three changes remove almost all of it. **The answer comes first**: whatever
|
||
the index already knows is shown immediately, and it is brought up to date *behind* that
|
||
answer, so no walk is ever between the user and their results. **Staying current is now a
|
||
catch-up, not a walk**: `StorageSource.names` lists a folder's names without reading a size
|
||
and a date for every child — three `stat` round trips per entry is what made the walk slow —
|
||
and the catch-up compares those names against the ones already indexed, reading metadata only
|
||
for names it has never seen. **A walk is now the exception**: it runs for a volume that has
|
||
never been indexed, for one whose rows were built under the other hidden-files preference
|
||
(the rows mean different things, so they cannot be caught up), for one not walked in full for
|
||
a week, or when the user taps Rebuild. A catch-up compares names alone, so those are exactly
|
||
the things it structurally cannot see: a file swapped for a different file of the same name,
|
||
and the preference's effect on what was ever counted. Two timestamps per volume keep that
|
||
honest, which is a schema change — v2 adds `full_at` and `hidden_included` with a real
|
||
migration that keeps the rows, because throwing them away would cost the next search a walk
|
||
it need not do. A folder that cannot be read now answers `null` rather than "empty", and a
|
||
catch-up leaves what it knows about it exactly as it was: deleting rows on a transient
|
||
failure is how an index loses things that are still there. Covered by tests that assert what
|
||
a pass *reads* rather than only what it writes — a fresh index makes no call to the storage
|
||
at all, a stale one reads only the new names, and a walk and a catch-up over a real temporary
|
||
tree keep the index level with files added and deleted between them. **288 JVM tests, lint
|
||
clean.** No device pass this time: the emulator's `/dev/kvm` disappeared from this
|
||
environment partway through (the previous round's device checks had already run), so this
|
||
round's evidence is the suite plus `AllFilesSourceTest` exercising the real
|
||
`File.list()` path — which is the one piece a fake cannot stand in for.
|
||
|
||
- **Six more kinds of file can be read in the app instead of guessed at.** Images and text had
|
||
a preview and nothing else did, so a zip, a PDF, a video, a song or an APK offered only
|
||
"hand it to another app" — and archives were the odd one out, because the app could *extract*
|
||
them all along and had no way to say what was in one. `ArchiveEngine` now lists an archive's
|
||
index without unpacking a byte of it, and takes a single row back out; five new screens cover
|
||
the rest:
|
||
|
||
- **Archive** — the contents as an indented tree with each entry's uncompressed size, or a
|
||
plain statement that the archive stores none, which a `.gz`/`.bz2`/`.xz` wrapper never does.
|
||
Tapping a file writes just that file out beside the archive, keeping its path *from inside*
|
||
the archive so two `notes.txt` from different folders cannot land on each other, and the bar
|
||
keeps extract-all. The index comes straight from the central directory where the backend can
|
||
seek (ZIP, 7z) and is streamed, decompressing as it walks, where it cannot.
|
||
- **PDF** — pages drawn one at a time by `android.graphics.pdf.PdfRenderer`, off the main
|
||
thread, on a white canvas: a fresh bitmap is transparent, so black text on a dark theme
|
||
would read as a blank page. The pages are a pager, so a swipe turns one and the bar's
|
||
chevrons move the same position rather than a second one that could disagree with it, and
|
||
the page either side is drawn ahead so a turn reveals a page rather than a spinner. The
|
||
page bar pads itself for the navigation-bar inset: a bar in an edge-to-edge window has to
|
||
do that for itself, because a `Scaffold` hands its insets to the content and not to the
|
||
bars it is given — the selection bar gets away with it only because `BottomAppBar` does
|
||
it internally. Without it the bar sat under the system buttons and taps aimed at a
|
||
chevron could land on Back.
|
||
- **Audio and video** — the platform's own decoders, `VideoView` with its transport controls
|
||
for video and a card with a scrubber for audio, both released on the way out.
|
||
- **HTML and SVG** — the platform web engine, with scripting off, network loads blocked and
|
||
file access disabled, because a preview has no business running anyone's code and this app's
|
||
promise is that nothing leaves the device. This also fixes a quiet bug: `.svg` is classified
|
||
as an image, so it went to a raster viewer with no SVG decoder and showed an empty frame.
|
||
- **APK** — label, package, version, `minSdk`/`targetSdk` and permissions, read from the
|
||
archive's own manifest by a new `ApkInspector` port: `PackageManager` behind an interface,
|
||
exactly as `DeleteRequestFactory` and `IncomingSource` keep a `Context` out of the ViewModel.
|
||
Nothing here installs anything.
|
||
|
||
All of it is platform APIs plus Commons Compress, which the build already had — no new
|
||
dependency and no native code. Which preview a tap reaches is decided in
|
||
`BrowserViewModel.open` and covered by tests; the viewers need a device, so each was driven
|
||
on one. The one place this narrows rather than widens: a preview that needs a real file —
|
||
image, PDF, video, audio — is now refused in the SAF fallback with "This preview needs full
|
||
file access" rather than opened, because no descriptor can be opened for a path that merely
|
||
looks absolute. Archive contents and markup are the two that work in both modes, because both
|
||
arrive through the backend as a stream.
|
||
|
||
- **A deliberate "Open with", and Share that takes more than one file.** Wiring the missing
|
||
tap was only half of it: an image or a text file still opens in this app's own viewer or
|
||
editor by design, so handing one to Gallery or a code editor needed a way to say so. There
|
||
is now an **Open with** action in the selection bar and in the details sheet, and both are
|
||
shown only for actual files — a folder is not something another app can be handed. Opening
|
||
is offered for a single item because `ACTION_VIEW` carries one URI; **Share** now works for
|
||
a whole selection and switches to `ACTION_SEND_MULTIPLE` with a list of URIs, which is what
|
||
a batch of photos is for — the old action was single-selection only, so a multi-file
|
||
selection had no share at all. `MimeTypes.forIntent` picks the type the batch carries: the
|
||
one they all share, or `*/*` when they differ, because sending a mixed batch as one of the
|
||
two would quietly hide every app that handles only the other.
|
||
|
||
The relay also fails honestly now instead of silently. In the SAF fallback the app cannot
|
||
hand anything out at all — its paths are relative to the granted tree while still looking
|
||
absolute, so `FileProvider` would happily build a `content://` URI pointing at nothing —
|
||
and that is now a message rather than a broken chooser. `BrowserViewModel.openWith` gates on
|
||
the access mode, and `contentUriFor`'s doc comment no longer claims a path's shape reveals
|
||
it (it does not: the check it described would pass for `/notes.txt` under SAF). The two
|
||
launcher failures — nothing on the device can open it, and no URI could be built — surface
|
||
as snackbars too, so a file that cannot be opened says so instead of doing nothing.
|
||
|
||
- **The trash names what is in it, per file, and can put a single item back.** Its rows were
|
||
one per *batch*, headed `1 item` — so a handful of files deleted one at a time produced a
|
||
column of "1 item" rows that said nothing about what was actually in there. A row is now
|
||
one file: its name, its size (or "Folder"), and the full path it will be restored to, with
|
||
a Restore action that puts that one item back. Batches are still visible as a timestamp
|
||
header grouping their rows, and a batch of several items keeps a "Restore all" action, so
|
||
the old "put back what I deleted at 14:20" is still a single tap. Making the row richer
|
||
needed no filesystem work: `TrashRecord` already carried name, type and size, so
|
||
`TrashedEntry` now carries them out of the index instead of a reader having to `stat`
|
||
every staged file.
|
||
|
||
- **A trash you can actually recover from, after the fact.** The staged-delete work left a
|
||
hole: the undo handle lived in memory, so a delete from a previous session was unreachable
|
||
even though the files were sitting right there on the volume — and because nothing on disk
|
||
recorded which folder an item came from, not even the *user* could put one back by hand
|
||
with any confidence. There is now a **Trash** entry in the overflow menu that lists what is
|
||
still there and puts a whole batch back.
|
||
|
||
Backing it is `data/`'s first persistence: a SQLite index of staged path, original path,
|
||
name, type, size and timestamp. Deliberately SQLite rather than Room — an annotation
|
||
processor would be a large addition to this build for one table and three queries — and
|
||
deliberately behind a `TrashIndex` port, so `domain/Trash` stays free of a `Context` and
|
||
the tests drive it with a map while the real one is tested against real SQLite.
|
||
|
||
The index is an **index, not the truth**: the staged files are the truth, and the two can
|
||
disagree — a user can delete `.plainfiles-trash` themselves, or pull the card. So it is
|
||
reconciled both ways. `restorableFrom` drops entries whose file has gone, `purgeExpired`
|
||
forgets entries whose folder has gone, and a row is written *after* the move, so a crash
|
||
between the two leaves an unindexed folder — harmless, and swept — rather than an entry
|
||
that could never restore anything. A `TrashScreen` row is one *batch*, not one file,
|
||
because a batch is what the user actually did and what the index records.
|
||
|
||
- **The app can receive a share, and act as a file picker.** The manifest had nothing but a
|
||
`LAUNCHER` filter, so no other app could hand Plain Files anything at all. It now registers
|
||
for `ACTION_SEND`/`SEND_MULTIPLE` — a bar appears over the browser, you navigate to the
|
||
folder you want, then "Save here" — and for `ACTION_GET_CONTENT`/`OPEN_DOCUMENT`, where
|
||
tapping a file hands it back through the existing `FileProvider`.
|
||
|
||
Both reuse the browser rather than adding a second screen, because both are the same task
|
||
underneath — navigate to a folder — with a different verb at the end. `data/` gains one
|
||
port, `IncomingSource`, so a `ContentResolver` stays out of the ViewModel exactly as
|
||
`DeleteRequestFactory` keeps one out of the delete path. A shared file is named from the
|
||
sender's `OpenableColumns` where it offers one, goes through the same `NameAllocator` a
|
||
paste does (so sharing the same file twice cannot overwrite the first copy), and is removed
|
||
again if it cannot be read rather than left as a zero-byte stub. While another app is
|
||
waiting for a file, tapping a *file* chooses it and tapping a *folder* still navigates —
|
||
without that the user could never reach the file they were trying to choose.
|
||
|
||
`ACTION_VIEW` is deliberately **not** registered: the app already hands files out through
|
||
the system chooser, and registering as a viewer as well would offer Plain Files back to
|
||
itself in that chooser. No new permissions — the merged release manifest still declares no
|
||
`INTERNET`.
|
||
|
||
- **Recursive search.** The query box used to filter the folder you were standing in, which
|
||
can only ever answer "is it here?" — and the usual question is "where did I put it?". It
|
||
now walks *down* from the current folder (`domain/FileSearch`) and streams matches as they
|
||
are found, because walking a whole volume takes tens of seconds and a blank screen for
|
||
that long reads as a hang. Rows say where the hit was found (`Folder · Documents/2026`) and
|
||
the app bar shows progress while the walk is still going.
|
||
|
||
Three deliberate bounds: a matching folder is still descended into, so a folder called
|
||
`holiday` cannot hide the matching files inside it; a hidden folder is skipped *whole*
|
||
rather than merely filtered out, so the preference cannot be leaked around; and the walk
|
||
stops at 200 matches, because a search returning ten thousand rows is not more useful than
|
||
one returning two hundred. Each keystroke cancels the walk in progress instead of queueing
|
||
behind it, and navigating away abandons the search — its results belonged to the folder
|
||
being left.
|
||
|
||
- **Undo for delete, rename and move.** A delete is now a *move* into a hidden
|
||
`.plainfiles-trash` folder on the same volume rather than a removal, so the snackbar can
|
||
offer to put it back; a rename and a pasted cut record how to reverse themselves. Four
|
||
deliberate limits, all carried in `domain/Trash`: the trash sits on the user's own volume,
|
||
so it counts against their free space until it expires (24 hours, purged opportunistically
|
||
so there is no startup hook to forget and no "empty trash" chore); a name taken again in
|
||
the meantime is never overwritten, so an item returns *beside* it under a free name; a
|
||
delete that went through Android's own dialog is not staged, because the system owns that
|
||
one and its trash with it; and when a pasted move's landing names cannot be established
|
||
with confidence, no undo is offered at all rather than risking putting the wrong file
|
||
back. The undo handle is cleared the moment it is used, so a second tap cannot replay it.
|
||
|
||
- **Keep the browsed folder and view settings across process death.** `SavedStateHandle`
|
||
carries the folder plus the sort key, direction, view mode and hidden-files setting. The
|
||
search query, the selection and the clipboard are deliberately *not* restored — a
|
||
restored query would hide the folder's contents on launch with no visible cause, and a
|
||
paste that survived a reboot would be a nasty surprise.
|
||
|
||
- **7z extraction.** Commons Compress already contained a pure-Java 7z reader
|
||
(`SevenZFile`), so this needed no new native code and no library beyond the XZ one the
|
||
`.xz` fix above required — the docs had simply assumed the format was off-limits and said
|
||
so in two places. The one real constraint is that 7z is the only format here that cannot
|
||
arrive through a stream: its header sits at the end of the file, so the reader has to
|
||
seek. That is now a capability the backend can advertise, `StorageSource.openSeekable`:
|
||
the all-files backend answers with a real `FileChannel`, and the SAF fallback answers
|
||
`null`, so a 7z in folder mode is refused with "needs a seekable file, which this storage
|
||
mode cannot provide" rather than failing obscurely. Entry names go through the same
|
||
`ArchivePath.safeRelativePath` zip-slip guard as every other format, with a test that
|
||
plants `../evil.txt` in a real 7z and asserts it lands nowhere. 7z *creation* stays
|
||
deferred, so ZIP is still the only writable format.
|
||
|
||
- **A two-pane layout for large screens.** A window at or above Material's "expanded"
|
||
breakpoint — 840 dp, which a 10-inch tablet in landscape at 1280 dp clears — now splits
|
||
into a master pane and a detail pane. Tapping a row highlights it, and the second pane
|
||
answers "what is in there?" without a navigation step: a folder's children, or a preview
|
||
plus the metadata for a file. Opening therefore moves to a double tap **on wide windows
|
||
only**, because once there is somewhere to show a selection a single tap has to mean
|
||
"show me"; phone (~411 dp) and 7-inch-portrait (~600 dp) windows keep the layout and the
|
||
tap behaviour they already had. The root screen previews the main volume on arrival
|
||
rather than leaving the second pane empty.
|
||
|
||
Found while taking the tablet screenshots, where it turned out the app had **no**
|
||
large-screen layout at all: on a 2560×1600 canvas every row was 2560 px wide with its
|
||
content stranded in the left ~240 px.
|
||
|
||
- **M10 — store assets.** The app is named **Plain Files** (one string; the `applicationId`
|
||
was already locked in M0). `tools/make-store-assets.sh` renders the 512×512 RGBA icon and
|
||
the 1024×500 RGB feature graphic from the app's own icon vectors, so the store icon *is*
|
||
the launcher icon rather than a lookalike that drifts after the next rebrand.
|
||
`tools/make-store-screenshots.sh` normalises `adb` captures to Play's 2:1 limit per form
|
||
factor — a phone at its native 1080×2400 is 2.22:1 and would be rejected outright.
|
||
[docs/STORE_LISTING.md](docs/STORE_LISTING.md) has the ready-to-paste copy, length-checked
|
||
against Play's limits, plus the permissions paragraph that the `MANAGE_EXTERNAL_STORAGE`
|
||
declaration requires. The feature graphic's copy was verified to survive Play's
|
||
centre-square crop by cropping the rendered PNG, not by trusting the arithmetic.
|
||
|
||
**Screenshots:** 5 phone shots at 1080×2160, 4 on a 7-inch tablet at 1200×1920 and 4 on a
|
||
10-inch tablet at 2560×1600 — all 24-bit RGB with no alpha, inside Play's 2:1 cap. The
|
||
tablet sets come from AOSP AVDs, because there was no tablet hardware to hand. The
|
||
generator now measures every capture and refuses to normalise a blank one: a headless AVD
|
||
that has not composed the app's first frame still draws its system bars, so a
|
||
pure-background capture is not obviously empty — and one had already been committed
|
||
before the check existed.
|
||
|
||
- **M9 — monetisation.** Decided: a **one-time paid app at €4.49, with a 14-day launch price
|
||
of €2.29**, both set in the Play Console. No ads, no subscription and no in-app purchases —
|
||
which means no Billing client, no `com.android.vending.BILLING` permission and no purchase
|
||
code in the APK at all; the price lives on the listing, not in the build. That is what
|
||
keeps the "no network permission, nothing leaves your device" claim true of the *shipped
|
||
artefact* rather than just the source. The rationale, the market anchors and the rejected
|
||
alternatives are in [docs/MONETISATION.md](docs/MONETISATION.md). Verified against the
|
||
rebuilt release APK: zero billing references, zero `INTERNET`, and the debug-only
|
||
pseudo-locale correctly absent.
|
||
|
||
- **M8 — localisation groundwork.** Every user-visible string lives in
|
||
`res/values/strings.xml` with real `<plurals>`, and an audit of the Compose sources found
|
||
exactly one genuine miss (above). The debug build type enables
|
||
`isPseudoLocalesEnabled`, so running it in English (XA) renders resource-backed text
|
||
accented and bracketed while anything hardcoded looks normal — a mechanical way to catch
|
||
the next one. Confirmed the pseudo-locale reaches the debug APK and not the release.
|
||
Byte units and the timestamp pattern stay unlocalised on purpose, documented in the
|
||
README along with the pseudo-locale check.
|
||
|
||
- **M7 — on-device validation.** The app was run on a real Xiaomi M2101K7AG (Android 12,
|
||
MIUI V140) rather than only emulator images. Verified on hardware: the first-run
|
||
permission prompt, "All files access" granted while the app is alive (picked up on
|
||
resume), browsing real storage, the text editor's read *and* write path (the write
|
||
checked independently with `adb shell cat`), the storage-usage screen, landscape layout,
|
||
and **graceful degradation when access is revoked mid-use**. The findings live in
|
||
[docs/EDGE_CASE_TESTING.md](docs/EDGE_CASE_TESTING.md), including what could *not* be
|
||
driven from the host — long-press multi-select is delivered as a plain tap by this MIUI
|
||
build, so the compress/extract buttons still want a manual pass.
|
||
|
||
- **M6 — release config and Play readiness.** An upload keystore (4096-bit RSA,
|
||
`CN=Hadrian Burkhardt, L=Potsdam, C=DE`) is generated and wired through the git-ignored
|
||
`keystore.properties`, so `bundleRelease` produces a signed, R8-minified, resource-shrunk
|
||
`.aab` (4 682 814 bytes) and `assembleRelease` a signed APK. `docs/PLAY_RELEASE_CHECKLIST.md`
|
||
walks the Play Console work, with step 4 covering the **Permissions Declaration Form** for
|
||
"All files access" — the declaration a file manager must make, and the one that can block
|
||
a release. `PRIVACY.md` is written to match the shipped permissions: no collection, no
|
||
sharing, no `INTERNET`. The shipped permission set is audited from the release APK.
|
||
|
||
- **M5 — analysis, archives and viewers.** `domain/UsageAnalysis` rolls up recursive folder
|
||
sizes and finds the largest files below a folder, which the new storage-usage screen
|
||
shows as a size and a share bar per row. `domain/ArchiveEngine` creates ZIP archives and
|
||
extracts ZIP, TAR, TAR.GZ, GZ, BZ2 and XZ using `java.util.zip` and Commons Compress —
|
||
all pure Java, so the app still ships no native code; RAR and 7z are recognised and
|
||
refused with a clear message rather than half-supported. Every archive entry name is
|
||
passed through `core/ArchivePath` before it becomes a path, so a crafted `../` entry
|
||
cannot write outside the destination. The browser gains Coil thumbnails in the list and
|
||
grid, a built-in image viewer and text editor, and compress/extract actions. 26 new JVM
|
||
tests (141 total), including a zip-slip archive that is refused while the same archive's
|
||
safe entry still extracts.
|
||
|
||
- **M4 — the browser UI.** The app is now usable end to end. `StorageLocation` (with a
|
||
real `StorageProvider` and a fake for tests) decides which backend is in effect;
|
||
`BrowserViewModel` owns navigation, sorting, hidden files, list/grid, search, selection
|
||
and the clipboard, and turns an `ASK` conflict into a prompt rather than a guess. The
|
||
screen offers roots and folder lists with breadcrumbs, a details sheet, new-folder /
|
||
rename / delete / conflict dialogs, and the "All files access" and SAF-folder prompts —
|
||
including an upgrade banner so declining full access never blocks browsing. Deletes go
|
||
through the platform's own confirmation when `MediaStore` can build one and fall back to
|
||
a direct delete otherwise; a `FileProvider` lets other apps open and receive files
|
||
without a `file://` URI ever leaving the process. 16 new JVM tests (115 total).
|
||
|
||
- **M3 — operations engine and foreground service.** `domain/FileOperations` turns the
|
||
backend primitives into copy, move, delete and rename: it walks folders, counts the
|
||
work up front so the progress bar has an honest denominator, checks for cancellation
|
||
between entries (never swallowing `CancellationException`), and reports per-item
|
||
failures instead of abandoning a batch because one file was unreadable. Conflicts are
|
||
resolved through the M1 `TransferPlanner` — `ASK` is deliberately surfaced to the
|
||
caller so the UI can prompt. A move tries the backend's atomic rename first and only
|
||
falls back to copy-then-delete when the backend cannot do it (the SAF path across
|
||
directories); the source is never deleted when its copy failed. `service/FileOpService`
|
||
is a `dataSync` foreground service that mirrors the engine's progress into an ongoing
|
||
notification, so a long operation survives the app being backgrounded. 23 new JVM
|
||
tests (99 total), including Robolectric coverage of the notification and the service
|
||
entering the foreground.
|
||
|
||
- **M2 — storage layer.** The two storage backends behind one interface. `AllFilesSource`
|
||
is `java.io.File` over every mounted volume (the "All files access" path);
|
||
`SafSource` is the Storage Access Framework fallback, reading a persisted tree URI
|
||
through `DocumentsContract` with `SafDocumentIds` doing the tree↔path mapping and
|
||
`SafGrant` owning the folder picker and its persistence. `VolumeRepository` enumerates
|
||
volumes via `StorageManager`/`StorageVolume` and reports free/total space through
|
||
`StatFs`; `StorageAccess` and `StorageAccessPolicy` decide between the two backends.
|
||
Both sources confine their blocking I/O to `Dispatchers.IO` and expose a `suspend`
|
||
contract. 32 new JVM tests (76 total), including a Robolectric run of `SafSource`
|
||
against a stand-in `DocumentsProvider`.
|
||
|
||
- **M1 — pure-Kotlin core engine.** An Android-free `core/` package holding the logic
|
||
every other layer builds on: `StoragePath` (normalised absolute paths that can never
|
||
escape the root), `FileEntry` with extension-based `FileKind` classification and a
|
||
`MimeTypes` lookup for "open with", natural-order `SortSpec` (directories always
|
||
first, `file2` before `file10`), human-readable `FileSize`, `NameMatcher`
|
||
(case-insensitive substring plus `*`/`?` globs), `NameAllocator` ("name (1)") and
|
||
`TransferPlanner` (conflict policies and move-into-self detection). Covered by
|
||
**44 JVM unit tests** across 10 classes.
|
||
|
||
- **M0 — project scaffold.** Gradle 9.3.1 / AGP 9.1.0 build with a version catalog,
|
||
`de.softwareapp_hb.filemanager` app module, a Compose Material 3 shell with the
|
||
branded blue theme (dynamic colour on Android 12+), an adaptive launcher icon, and
|
||
the storage-access permission set (all files access + SAF fallback). Debug APK
|
||
builds, installs and runs on an AOSP Android 12 AVD.
|