667a85f80c
The staged-delete work left a hole. The undo handle lived in BrowserUiState, so a delete from an earlier session was unreachable even though the files were sitting 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 confidence. Adding a Trash screen alone would not have fixed it: the recovery data did not survive the process. So the origin is now written at delete time. data/ gains its first persistence: a SQLite index (staged path, original path, name, type, size, timestamp). SQLite rather than Room because an annotation processor is a large addition to this build for one table and three queries, and behind a TrashIndex port so domain/Trash keeps no Context while the tests drive it with a map and the real one is still tested against real SQLite. New Trash entry in the overflow menu lists batches and restores a whole one. The index is an index, not the truth: the staged files are the truth, and the two can disagree if a user empties the folder by hand or pulls a card. Reconciled both ways - restorableFrom drops entries whose file has gone, purgeExpired forgets entries whose folder has gone and sweeps folders no entry mentions - and rows are written after the move, so a crash leaves an unindexed folder (harmless, swept) rather than an entry that could never restore. Verified on hardware: delete, force-stop, relaunch, restore. Also corrects the docs where they claimed input swipe x y x y 900 is delivered as a plain tap on MIUI. It does open the selection bar - it was driven that way - but it has also landed as a tap, so it is flaky rather than impossible.
262 lines
18 KiB
Markdown
262 lines
18 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]
|
||
|
||
### Fixed
|
||
|
||
- **`.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 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 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.
|