ba92cf392d
Commons Compress declares its LZMA/LZMA2 support against org.tukaani:xz as an *optional* dependency, so nothing pulled it in transitively and nothing failed at build time. The app nevertheless advertised .xz as extractable, so the first user to open one would have got NoClassDefFoundError: org/tukaani/xz/FilterOptions. XZCompressorInputStream references org/tukaani/xz/XZInputStream directly. The library is now declared, and a round-trip test covers the path that was silently missing. That same library is what 7z needs, and Commons Compress already ships a pure-Java 7z reader, so 7z extraction costs no native code and no further dependency. The docs had assumed the format was off limits and said so in three places, including the customer-facing store copy. 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 advertises, StorageSource.openSeekable: the all-files backend answers with a real FileChannel, the SAF fallback with null, so folder mode reports needs-a-seekable-file instead of failing obscurely. Entry names go through the same ArchivePath.safeRelativePath zip-slip guard. 7z creation stays deferred.
187 lines
13 KiB
Markdown
187 lines
13 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
|
||
|
||
- **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.
|