The profile is real: 13193 lines in app/src/main/baseline-prof.txt, 584 of them the app's own classes, bundled into the release APK as assets/dexopt/baseline.prof. It is produced by a new :baselineprofile test module that walks the first journey - roots screen, one volume opened - under the androidx.baselineprofile plugin, and :app now depends on androidx.profileinstaller so the bundled profile is applied at install time. Two device facts decided how it was made. Collection requires API 33+ (BaselineProfileRule refuses Android 12 outright), so it ran on the API 35 AVD that was already in ~/.android/avd; that emulator is software-rendered and needs androidx.benchmark.suppressErrors=EMULATOR for any macrobenchmark. And the release build needed profileable android:shell=true, which is why app/src/release/AndroidManifest.xml exists and says why: the same APK is both the thing measured and the thing shipped, so the profile's class names are the ones in the shipped dex. The effect is not measured, and PERFORMANCE.md says so: 7404 ms median without the profile against 9005 ms with it, run to run, because a TCG emulator varies by more than a profile can buy. What remains is one run on a physical API 33+ phone, with the two compile commands documented. The emulator did settle the other open question - FrameTimingMetric there reports frameDurationCpuMs and frameOverrunMs, the metrics this phone cannot produce at all. :baselineprofile is compiled by tools/verify.sh --release like :benchmark, so it cannot rot between device runs.
18 KiB
Roadmap
File Manager is built against a fixed milestone plan (M0–M15). This file is the single source of truth for what is done and what remains. M16 is specified below but not scheduled — it is the first item of the post-1.0 queue that is a feature rather than a safeguard.
Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (proposed, not scheduled)
Milestones
| # | Milestone | Status | Evidence / notes |
|---|---|---|---|
| M0 | Toolchain & scaffold | ✅ | Gradle 9.3.1 + AGP 9.1.0 wrapper, version catalog, app module, Compose theme, debug APK builds, installs and runs on the phone AVD: package de.softwareapp_hb.filemanager.debug, versionCode 1, minSdk 26/targetSdk 36, no INTERNET permission, lintDebug clean (one expected UnusedResources for the not-yet-used notification icon) |
| M1 | Pure-Kotlin core + JVM tests | ✅ | core/ is Android-free: StoragePath (normalised absolute paths that cannot escape the root), FileEntry + FileKind classification, MimeTypes, NaturalOrder + SortSpec (directories first; file2 before file10), FileSize, NameMatcher (substring + */? glob), NameAllocator ("name (1)") and TransferPlanner (conflict policies, move-into-self). 44 JVM tests across 10 classes, all green |
| M2 | Storage layer | ✅ | StorageSource with two backends — AllFilesSource (java.io.File under all-files access) and SafSource (DocumentsContract over a persisted tree URI), the latter split so the tree↔path mapping (SafDocumentIds) is exhaustively testable; SafGrant for the picker and its persistence; VolumeRepository (StorageManager/StorageVolume + StatFs); StorageAccess + StorageAccessPolicy for the all-files/SAF decision. 76 JVM tests total (32 added here) |
| M3 | Operations engine + service | ✅ | domain/FileOperations composes the backend primitives into copy/move/delete/rename with recursive walking, an honest progress total, cooperative cancellation between entries and per-item failure reporting; conflicts are decided through the M1 TransferPlanner (ASK is surfaced for the UI to resolve). service/FileOpService is a dataSync foreground service mirroring progress into a notification (FileOpNotifications + text/TextRes). 99 JVM tests total (23 added here) |
| M4 | Compose UI | ✅ | BrowserViewModel + BrowserUiState over StorageLocation; browser screen with list/grid, breadcrumbs, in-folder search, a sort/hidden-files/view sheet, multi-select action bar, details sheet, and new-folder/rename/delete/conflict dialogs; a roots view and the "All files access" + SAF-folder prompts; data/MediaStoreDeleter routes a delete through the platform's own dialog where it can and falls back to a direct delete; a FileProvider backs "open with" and share. 115 JVM tests total (16 added here) |
| M5 | Analysis, archives, viewers | ✅ | domain/UsageAnalysis measures recursive folder sizes and finds the largest files, shown by a bar-per-row storage-usage screen; domain/ArchiveEngine creates ZIP and extracts ZIP/TAR/TAR.GZ/GZ/BZ2/XZ/7z using java.util.zip + Commons Compress + XZ for Java (no native code), with every entry name filtered through core/ArchivePath against zip-slip; a Coil-backed image viewer, thumbnails in the list and grid, a text editor, and compress/extract in the selection bar. 141 JVM tests total (26 added here) |
| M6 | Release config & Play readiness | ✅ | Upload keystore generated (4096-bit RSA, CN=Hadrian Burkhardt) and wired through the git-ignored keystore.properties; bundleRelease + assembleRelease verified — 4 682 814-byte .aab (sha256 9014627a…), 2 081 955-byte APK signed by the upload key, R8 mapping kept. PLAY_RELEASE_CHECKLIST (incl. the Permissions Declaration Form for "All files access", the Data safety answers and the shipped-permission audit) and PRIVACY.md written for a restricted-permission app. Shipped permission set audited: no INTERNET |
| M7 | On-device validation & hardening | ✅ | Run on a real Xiaomi M2101K7AG (Android 12 / MIUI V140) — see EDGE_CASE_TESTING. Verified on hardware: the first-run permission prompt, granting "All files access" while the app is alive (picked up on resume), browsing real storage, the text editor's read and write path (independently checked with adb shell cat), the storage-usage screen, landscape layout, and graceful degradation when access is revoked mid-use. Found and fixed two real defects: misleading first-run copy ("Full access was declined" on a device where nothing had been declined), and a usage screen that stayed blank for ~45–60 s on a 55 GB volume — now one listing per directory instead of a stat per node, with rows streamed out as they are measured. 142 JVM tests |
| M8 | Localisation groundwork | ✅ | Every user-visible string is in res/values/strings.xml with real <plurals>, and text/TextRes.kt maps both OperationKind and FileKind onto resources so the screen, the notification and the details sheet cannot drift. The audit found a genuine miss: the details sheet rendered FileKind.name, i.e. untranslatable English — now a resource. isPseudoLocalesEnabled = true on the debug build makes "did every string come from resources?" mechanically checkable; verified en-rXA is present in the debug APK and the flag is debug-only. Byte units and the timestamp pattern are deliberately unlocalised, and the README says why. 142 JVM tests |
| M9 | Monetisation | ✅ | Decided: one-time paid — €4.49 base with a 14-day launch price of €2.29, both set in the Play Console (Products › App pricing), so there is no Billing client, no com.android.vending.BILLING permission and no purchase code. Rationale, market anchors and Console steps: MONETISATION. Verified against the release APK: zero billing and zero INTERNET references. The price must be set before the first upload — Play allows paid → free but never free → paid |
| M10 | Store assets | ✅ | Name decided: Plain Files (one string; applicationId already locked). tools/make-store-assets.sh renders the 512×512 RGBA icon and the 1024×500 RGB feature graphic from the app's own vectors, so the store icon is the launcher icon; tools/make-store-screenshots.sh normalises adb captures to Play's 2:1 cap per form factor (phone crop default 93/130 px, tablets 0). STORE_LISTING carries length-checked copy (name 11/30, short 67/80, full 2730/4000), the permissions paragraph the MANAGE_EXTERNAL_STORAGE declaration requires, and the graphics table. The feature graphic's copy was verified to survive Play's centre-square crop by cropping the rendered PNG. Screenshots captured: 5 phone (1080×2160), 4 on a 7-inch tablet (1200×1920) and 4 on a 10-inch tablet (2560×1600), all 24-bit RGB, inside the 2:1 cap. The tablet sets came from AOSP AVDs. The normaliser refuses a capture whose app area is blank, which is how a pure-background capture was caught before it reached the listing |
| M11 | Library views | ✅ | A Libraries section on the root screen — Images, Videos, Audio, Documents, Archives, Recent — each row carrying how many files the index knows of that kind, each opening an ordinary listing (thumbnails, sort, multi-select, details, the existing viewers) answered from the index instead of from a directory. core/FileCategory groups FileKinds and is asserted to leave DIRECTORY, APK and OTHER out deliberately; SearchIndex gained byKind/recent/kindCounts, and SQLite schema v3 adds the derived kind column — the migration rebuilds the table and drops the volume stamps, because a stamp whose rows have gone would make the next search trust an empty index. A library obeys the indexed search's own two rules: the answer comes first with the walk behind it, and every row is confirmed against the filesystem before it is shown. Not included, and recorded in the CHANGELOG as decisions rather than omissions: Downloads and New files, and restoring a library across process death. 308 JVM tests (19 added here) |
| M12 | Duplicate finder | ✅ | Find duplicates in the overflow. Three passes, cheapest first: size (the index's duplicateCandidates query — one query and no reading at all), the first 64 KB of every candidate (a filter that may only rule files out), and the whole file to confirm (domain/DuplicateFinder, SHA-256, cancellable between files and between read buffers). Groups are ordered by reclaimable space; each header states the copies and what they cost; every row carries its folder, because identical names with no paths are unactionable; and nothing is preselected — the delete is the app's ordinary one, through the trash and the undo snackbar. A file that cannot be read is counted and reported rather than skipped, and the empty state says how many files were compared — or that the index has never run and there was nothing to compare. 323 JVM tests (15 added). Verified on the Xiaomi phone against four genuine groups in its own storage; the delete was deliberately not pressed on a device that is not ours — see EDGE_CASE_TESTING |
| M13 | Video thumbnails | ✅ | A video row shows a frame of the video instead of a film icon: images and videos take the same path through Coil, with the video request asking for a frame a second in. One dependency — coil-video at the Coil version already pinned, whose POM adds only coil-core/stdlib/core-ktx — the platform's own MediaMetadataRetriever behind it, no new permission (no READ_MEDIA_VIDEO), and the decoder registered explicitly against an ImageLoader in FileManagerApplication — because R8 strips coil-video's ServiceLoader entry from a release build, which would otherwise have left frames decoding in debug and quietly falling back to icons for real users. A decode that fails settles on the icon rather than an empty square. Verified on the phone against two purpose-made clips and a real video of the device's own, with the demo library's stand-ins falling back as intended, and re-verified on the release APK. 324 JVM tests |
| M14 | Batch rename with sequence continuation | ✅ | Selecting more than one file and choosing Rename opens a pattern dialog: a name, a start number, and a live preview of the names it will produce. core/BatchRename plans the whole batch before a single file moves and returns one of three answers — Ready, Blocked (naming the target the folder already holds) or NothingToDo — with the deliberate absence of a fourth: a batch that renamed nine of twelve and stopped would leave the user to work out which nine. The numbering continues the folder's own series nextNumber from the highest base (n) it finds, which is the market survey's most-helpful complaint answered by name; the extension is kept per file, the series is counted across extensions, and continuing beats filling the first gap. SelectionBar now offers Rename to any selection (one file keeps the name dialog, several get the pattern one), and the batch's renames are one Undo.Relocate step. The same commit hardens the single rename: core/FileNames.isUsableName refuses a name that is not one path segment, because notes/final.txt was being resolved as a path and moved the file instead of renaming it. 350 JVM tests (26 added) |
| M15 | Favourites | ✅ | A star in the app bar of any folder, and a Favourites section on the home screen above the libraries — absent while empty, so a first-time user still meets the libraries first. data/BookmarkStore + SqliteBookmarkStore keep one path per row in their own database: a reference, never a copy, so the list costs one query and no filesystem work. BrowserUiState.Bookmark carries an exists flag, and a folder that has gone is kept and marked rather than deleted — an unmounted card and a deleted folder are the same thing from here — with opening re-checked at that moment so the answer is a message instead of an empty listing. The row's filled star is the removal (the mark that says "favourite" is the thing you tap to stop it being one), there is no star in a library or on the root screen, and no Undo for a bookmark because that type is "the last change to the filesystem" and a favourite is not one. 371 JVM tests (16 added) |
Key decisions
| Decision | Choice | Rationale |
|---|---|---|
| Language / UI | Kotlin + Jetpack Compose | Mirrors the sibling app's proven, installed toolchain; no NDK |
| Storage access | MANAGE_EXTERNAL_STORAGE first, SAF fallback |
Google Play explicitly permits "All files access" for file managers; SAF keeps the app usable if the user declines |
| Connectivity | Local-first (no INTERNET in v1) |
Preserves the "nothing leaves the device" claim and keeps the Data Safety form trivial |
| File sharing | Receives SEND/SEND_MULTIPLE, serves GET_CONTENT/OPEN_DOCUMENT, and deliberately does not register ACTION_VIEW |
A file manager should be able to receive a file and to supply one, and neither costs a permission. Registering as a viewer as well would put Plain Files in the "open with" chooser the app itself uses, offering it back to itself |
| Performance | Measured, not assumed: PERFORMANCE holds the numbers and tools/measure-startup.sh takes one of them |
The survey's top complaint is latency, so a speed claim has to be a number — and a number nobody can reproduce is worse than none |
| Quality gate | tools/verify.sh — map check, detekt, tests, lint; --release adds R8 — run by CI, by the pre-push hook and by hand |
One command that means the same thing in all three places. The detekt baseline is what makes adopting static analysis possible without a backlog-clearing week, while still failing on anything new |
| Monetisation | One-time paid (€4.49; €2.29 launch price), set in the Console | No Billing client, no BILLING permission, no purchase code — which is what keeps "nothing leaves the device" true in the shipped APK. Free → paid is impossible after the first upload, so the price must be set beforehand. See MONETISATION |
Deliberately deferred (post-1.0)
- Cloud storage (Drive/OneDrive/Dropbox/…); network clients (SMB/FTP/SFTP/WebDAV); FTP/Web
server. All of these need
INTERNET, which is the one permission the app deliberately does not declare — see the connectivity decision above. - App manager (
QUERY_ALL_PACKAGES), APK installation (REQUEST_INSTALL_PACKAGES). - RAR archives (the format needs native code) and 7z creation; root explorer; encryption vault; themes/icon packs.
Candidates from the competitive analysis
A survey of the market leader (its features, and what its users praise and complain about) is in COMPETITIVE_ANALYSIS. Its gaps that cost no new permission are listed there as 💡 candidates. Four of them have since been shipped: category libraries (M11), the duplicate finder (M12), batch rename with sequence continuation (M14) and favourites (M15).
The last one standing there — the post-paste "copy again" and "return to source folder" — is
closed as superseded, not deferred: both exist only because that app's flow makes you leave the
source folder to paste. Copy to… / Move to… (in the queue below) removes that constraint, and with it
both features: you never leave the source, so the selection is still there for the next destination, and
there is no "back to source" to offer. Keeping them would be building a workaround for a limitation we
would no longer have.
The queue after 1.0
Ordered by what it protects, not by what is fun. Nothing here is scheduled; the first two are what a release should not go out without.
- Baseline profile — generated and committed:
app/src/main/baseline-prof.txtis bundled in the release APK, written by the:baselineprofilemodule. What remains is its measurement: collection needs a device with API 33+, and the phone is Android 12, while the API 35 emulator here is too noisy to resolve the delta. One run on a physical API 33+ device —compile --resetversuscompile -m speed-profile— turns it from "bundled" into a number in PERFORMANCE. - Thumbnail hardening before the next release — an explicit Coil
ImageLoaderwith a decided cache policy and a placeholder, plus a large-folder device pass recorded in EDGE_CASE_TESTING. The only item here that touches code changed late in 1.0. - The scaling numbers —
./tools/benchmark-fixture.sh --count 20000and the existing benchmarks: answers whether a full SD card is a performance problem before a user with one finds out. - Copy to… / Move to… (M16) — the comfort feature, specified below.
- A CI run of the benchmarks on an emulator — removes the phone from the loop and reports
frameDurationCpuMs, which this device cannot.
M16 — Copy to… / Move to…
What: from a selection, choose a destination folder and start the copy (or move) immediately, without leaving the folder you are in.
Why this shape: BrowserViewModel.paste() already calls planTransfer(paths, target, policy) with the
current folder as target; the engine is target-agnostic, so the operation half needs no new machinery —
FileOpService and the conflict prompt work regardless of what is on screen. What is missing is a
destination picker.
Acceptance criteria
- Selecting files and choosing Copy to… opens a folder picker: folders only, one level at a time, with Up and breadcrumbs, every root the app can see (internal and SD card), and the granted tree in SAF mode.
- Confirming copies into the chosen folder and leaves the user where they were, with the selection
still intact — so a second destination is two taps.
Move to…is the same picker withcut = true. - A name conflict asks the same question as a paste does, with the picked folder named in it, and resolve applies to that folder.
- Progress belongs to the existing operation notification, not to the screen the user is on.
- After a copy, a message names the destination and offers to show it. After a move the selection is cleared and the listing refreshed, because the rows are gone.
- Tests: plan/enqueue with an explicit target; the conflict prompt carrying the picked folder; selection survives a copy and is cleared by a move; the picker lists folders only.
Not in scope: drag and drop, a two-pane phone layout, remembering recent destinations.