55dc2293b2
A window at or above Material expanded breakpoint (840dp) now splits into a master pane and a detail pane: tapping a row highlights it and the second pane shows a folder children, or a preview plus the metadata for a file. Opening moves to a double tap on wide windows only; phones (~411dp) and the 7-inch in portrait (~600dp) keep their layout and their tap behaviour. Found while taking the tablet screenshots: the app had no large-screen layout at all, so on a 2560x1600 canvas every row was 2560px wide with its content stranded in the left ~240px. The tablet sets are re-shot on this build. That also caught a blank capture (7-inch 01-roots) that had already been committed: a headless AVD draws its system bars before Compose composes its first frame, so the file was not obviously empty. make-store-screenshots.sh now measures each capture app area and refuses to normalise one that is blank.
167 lines
11 KiB
Markdown
167 lines
11 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
|
||
|
||
- **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 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.
|