17078bf613
The last acceptance criterion M16 had left open. After Copy to..., the snackbar reads 'Copied to AADst' with a Show button; after a move to a picked folder it keeps the undoable message instead, because the sources are gone and Show would lead nowhere. Two things needed care rather than a string. The action button was hard-wired to undo() - the label was a parameter beside it, which let the two disagree - so UiMessage now carries a MessageAction, and the label follows from it: Undo for an undo, Show for a jump. And a message cannot name a folder from the path alone: a volume root has no name of its own, and internal storage's is 0. The message carries the picked PickerFolder, and the screen resolves its label the way breadcrumbs do - so copying into the root reads 'Copied to Internal storage', which is the case a plain path.name would have got wrong. Four tests: the message names the destination and offers Show; Show opens it; a paste into the folder on screen still names nothing and offers nothing (its destination is the screen); a move to a picked folder offers Undo. All four driven through the view model, and both snackbar variants checked on the phone, including the volume-root one. detekt's SpreadOperator baseline entries moved with the expression, so the message building is now a small helper with a deliberate suppression and a reason, and the baseline was regenerated: three obsolete entries removed, none added.
844 lines
67 KiB
Markdown
844 lines
67 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
|
||
|
||
- **Copy to… and Move to… chose a destination instead of making you go there.** The selection bar's
|
||
overflow now offers both verbs: a folder picker opens — the volumes first, then folders one level at a
|
||
time, with the trail above and Up beside it — and confirming starts the copy (or the move) into that
|
||
folder **without leaving the one you are in**. The files stay selected afterwards, so sending the same
|
||
set to a second destination is two taps. A name that already exists there asks exactly what Paste asks,
|
||
naming that folder; progress belongs to the same operation notification, and a move clears the selection
|
||
because those rows are gone. A finished copy answers with **"Copied to *folder*"** and a **Show** button
|
||
that opens the destination — so the file you just put somewhere is one tap away from being looked at. Underneath it is the paste path with an explicit target: the engine was
|
||
always target-agnostic, so the new part is the picker and the rules around it.
|
||
|
||
- **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.
|
||
|
||
- **The user interface is now tested as a user sees it, and a debug build tells on itself.** Fifteen
|
||
rendering tests across four files are the first tests in this project that compose a screen:
|
||
the app bar's star exists only where there is a folder to star and says *Add* or *Remove* according
|
||
to the state, the selection bar offers Rename for several files and Details only for one, the batch
|
||
rename dialog spells out the names it will produce and refuses a collision with the file named, the
|
||
shared name dialog refuses a slash as it is typed, and a favourite that has gone says *Folder not
|
||
found* instead of showing a path that is not there. They run on the JVM through Robolectric — the
|
||
Compose test artifacts plus the test manifest — so they belong to the same `tools/verify.sh` gate as
|
||
everything else instead of needing a device; **386 JVM tests** in total. Writing them turned up a
|
||
behaviour worth recording: the batch dialog's numbering continues the folder's series, so a
|
||
collision can only be *shown* by forcing the start number back, which is exactly why the feature
|
||
works in practice.
|
||
|
||
The debug build now enables **StrictMode** (disk reads and writes, network, leaked closables, logged
|
||
rather than fatal) and attaches **LeakCanary**. A file manager is the worst possible app for a
|
||
blocking disk read on the main thread — every listing, `stat` and thumbnail is one — and a debug
|
||
build is the last place where noticing it is still cheap. Logging rather than a crash is deliberate:
|
||
turning a slow screen into a crash on someone's device is a worse trade than a line in logcat. The
|
||
flag is read from the manifest rather than from `BuildConfig`, which this build does not generate.
|
||
|
||
**Performance has a number for the first time.** `tools/measure-startup.sh` takes cold start with
|
||
`am start -W` — median of several runs, one warm-up discarded because the first launch after an
|
||
install pays for dexopt — and [docs/PERFORMANCE.md](docs/PERFORMANCE.md) records the result: **1 814
|
||
ms** median over five runs on the phone, in the debug build, which is the slow case by construction
|
||
(StrictMode, LeakCanary, no R8). The same file states what is *not* measured — folder open at 1 000 /
|
||
5 000 / 20 000 files, thumbnail paint while scrolling, and keystroke-to-result — with the method each
|
||
would use and the honest reason they are missing: they need a `:benchmark` module and a profileable
|
||
build under the release package id, and this device waits for a confirmation dialog on the first
|
||
install of a new package id. The module now exists — `benchmark/`, four macrobenchmark tests, its own
|
||
`.perf` package id so it cannot collide with the test APK or with a release install — and
|
||
`tools/verify.sh --release` compiles it so it cannot rot between device runs. What it has not done is
|
||
produce a number: on this phone `adb install` of a new package id is refused with
|
||
`INSTALL_FAILED_USER_RESTRICTED`, so the two APKs are pushed to `/sdcard/Download/` for a manual
|
||
install and the run is waiting on that. Two build facts had to be learned the hard way and are
|
||
written next to the code: the app under test needs its own package id (`.perf`), because the
|
||
benchmark module's own APK already owns `.benchmark` and an app with that id collides with it; and
|
||
`testBuildType` is behind a `-PbenchmarkBuild` flag, because setting it unconditionally makes
|
||
`benchmark` the variant *unit* tests run against and `:app:testDebugUnitTest` — the task this
|
||
project's whole gate is built on — stops existing.
|
||
|
||
### 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.
|