Preview files inside an archive, and search every volume from an index

The archive preview could say what a zip held but not show any of it, and the search could only answer "is it here?" for the folder tree the user was standing in. Both are now answered.

ArchiveEngine gains 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. extractEntry is rewritten on top of it, so there is one implementation of "reach this entry" rather than two that could disagree, and the now-dead extractFromZip and only= parameters are removed.

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 and under its 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. 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. A copy has no file to save back to, so the text editor shows no Save and markup offers no editor; closing returns to the archive rather than out of it.

SearchIndex is a small port over SQLite beside the trash index, inheriting that one's doctrine that an index is not the truth: a reader drops rows whose file has gone and forgets them, freshness is reported rather than assumed, and a volume's rows are replaced wholesale so an interrupted pass leaves the previous listing rather than half of this one. Timestamps are written only when a pass finishes, so a walk cut short by a new keystroke reads as older than it is, which is the safe direction to be wrong in. Names are matched with NameMatcher's semantics translated into LIKE - substring, anchored glob, and a literal % or _ escaped - because a search that quietly widens what was typed is what an index does badly.

The search field gains a scope toggle, and every search everywhere carries the age of the index and a way to rebuild it, because a search that silently omits what arrived since the last walk is worse than one that spends a minute being sure. Rows are written in batches of 500 and the counter reported every 100, since a row reading "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 rather than inferred: a .txt and a .png inside a real zip, both previewed from the archive list with their own names, the copy gone on close and the archive list restored behind it; then the first search building the index with a full walk, and a search after an app restart answering instantly and finding a file in Download/cline-zip while browsing Internal storage. Watching it caught two lies, both fixed here: the empty state claimed "Nothing matches" while the index was still being written, and two progress bars stacked on each other.

268 JVM tests, lint clean, release APK builds and installs. The store listing gains the archive preview and the scoped search and is re-measured at 2730/4000.
This commit is contained in:
2026-10-16 14:33:18 +02:00
parent cd0fdff31b
commit ae44cdb994
35 changed files with 4519 additions and 148 deletions
+160
View File
@@ -77,6 +77,40 @@ upload needs a **higher `versionCode`** than every previous upload.
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
@@ -84,6 +118,132 @@ upload needs a **higher `versionCode`** than every previous upload.
### 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.
- **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