Files
file-manager/CHANGELOG.md
T
SoftwareApp d2183ebc79 Keep the search index current without re-walking the volume
A search everywhere rebuilt the whole index once it was older than fifteen minutes, so every session paid for a walk of the entire volume - tens of seconds of reading before a single result - even though almost nothing had changed.

StorageSource gains names(), which lists a folder's names without reading a size and a date for every child. That is the difference between a walk and a catch-up: list() costs three stat round trips per entry on a phone's mounted storage, and a name index does not need any of them for entries it already knows. SearchIndexer.refresh() compares the names a folder holds now against the names already indexed and reads metadata only for names it has never seen, writing nothing at all for a folder that has not changed.

The search itself now answers first and catches up behind the answer: whatever the index knows is shown immediately, and the pass that makes it current runs afterwards and answers again, which is the whole of "I just downloaded it, so why is it not here?". A full walk is now the exception - a volume never indexed, one whose rows were built under the other hidden-files preference, one not walked in full for a week, or the explicit Rebuild - because a catch-up compares names and so cannot see a file swapped for a different file of the same name, nor what a preference change means for rows that were never counted. Two timestamps per volume keep that distinction, which is a schema change: v2 adds full_at and hidden_included with a real migration that keeps the rows, since throwing them away would cost the next search the walk it just avoided.

A folder that cannot be read now answers null rather than empty, and a catch-up leaves what it knows about it alone: concluding "everything inside has gone" from a transient failure is how an index loses things that are still there.

The tests assert what a pass *reads*, which is the point of the change: a current index makes no call to the storage at all, a stale one reads only the names it has never seen, and a walk followed by a catch-up over a real temporary tree keeps the index level with files and folders added and deleted between them. AllFilesSourceTest covers the new names() against the real filesystem, including the null for an unreadable folder. 288 JVM tests, lint clean.

No device pass this time: /dev/kvm disappeared from this environment partway through, so the emulator could not be started again. The device checks for the index itself were made in the previous round.
2026-10-16 17:48:31 +02:00

495 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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]
### Fixed
- **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
- **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.