Files
file-manager/CHANGELOG.md
T
SoftwareApp cd0fdff31b Unpack .tar.bz2 and .tar.xz, which only half-extracted
formatOf matched .tar.gz/.tgz as a combined format and then fell through to the bare .bz2 and .xz wrappers, so a .tar.bz2 was read as one compressed file: extracting it decompressed a single layer and left 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 they all behave alike.

Found by extracting one archive of every supported format through the UI on the API 35 AVD rather than trusting the suite. A real z4.tar.bz2 left z4/z4.tar - raw ustar headers and all - beside z3.tar.gz fully unpacked to z3/a.txt and z3/sub/b.txt.

TAR_BZIP2 and TAR_XZ now sit alongside TAR_GZIP, detection consumes the whole tar suffix (.tar.bz2/.tbz2, .tar.xz/.txz) before the bare compressor, and the engine gains the two readArchive branches. The bare spellings still mean a single compressed file.

The suite had a gap that let this hide: only ZIP, GZIP, XZ and 7z had round-trip tests, so neither plain TAR nor TAR_GZIP did, and there was nothing for the asymmetry to contradict. All four tar spellings have one now, built in memory with the same library that reads them. 212 JVM tests, lint clean.

Also records the format sweep in EDGE_CASE_TESTING and re-measures the release artefacts (aab 4 838 235 / sha256 e1d23130..., apk 2 155 455 / sha256 37fb46cb...). The APK byte count happened to land on the same figure as the previous build, so the new literals were confirmed present in the shipped dex rather than trusted to the size.
2026-10-15 12:14:39 +02:00

22 KiB
Raw Blame History

Changelog

All notable changes to File Manager are documented in this file.

The format follows Keep a Changelog, and the project uses Semantic Versioning.

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 IconButtons — 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-stating 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 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. 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, 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.