17078bf613ae608905879df6287af2345785f805
43 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
17078bf613 |
A copy into a picked folder says where it went, and Show takes you there
The last acceptance criterion M16 had left open. After Copy to..., the snackbar reads 'Copied to AADst' with a Show button; after a move to a picked folder it keeps the undoable message instead, because the sources are gone and Show would lead nowhere. Two things needed care rather than a string. The action button was hard-wired to undo() - the label was a parameter beside it, which let the two disagree - so UiMessage now carries a MessageAction, and the label follows from it: Undo for an undo, Show for a jump. And a message cannot name a folder from the path alone: a volume root has no name of its own, and internal storage's is 0. The message carries the picked PickerFolder, and the screen resolves its label the way breadcrumbs do - so copying into the root reads 'Copied to Internal storage', which is the case a plain path.name would have got wrong. Four tests: the message names the destination and offers Show; Show opens it; a paste into the folder on screen still names nothing and offers nothing (its destination is the screen); a move to a picked folder offers Undo. All four driven through the view model, and both snackbar variants checked on the phone, including the volume-root one. detekt's SpreadOperator baseline entries moved with the expression, so the message building is now a small helper with a deliberate suppression and a reason, and the baseline was regenerated: three obsolete entries removed, none added. |
||
|
|
1ec71381a0 |
The picker names a volume the way the browser does
Two fixes from driving Copy to… and Move to… on the phone. The dialog was nested inside the conflict-prompt block, so it could only appear when a conflict was already on screen - which is never. Nothing in the unit tests could see that: they test the view model, and the view model was right. And the picker named internal storage by the platform's own name for it, 0: as the volume row, and in the trail above it (0 / AADst). The roots screen has always shown Internal storage there; the picker now follows the same rule, through a label resource on PickerFolder rather than a second copy of the string logic. A test pins the label on both the row and the crumb. On the device: copy leaves the source and the selection in place, move empties the source, clears the selection and says Moved 1 item. Recorded in EDGE_CASE_TESTING.md. |
||
|
|
f3abef6c44 |
Copy to… and Move to… chose a destination instead of making you go there
The selection bar's overflow offers both verbs. Confirming opens a folder picker - the volumes first, then folders one level at a time, with the trail above and Up beside it - and starts the copy or the move into the picked folder without leaving the one you are in. The files stay selected after a copy, so sending the same set to a second destination is two taps; a move clears them because the rows are gone. Underneath it is the paste path with an explicit target: planTransfer and transfer were always target-agnostic, so the new code is the picker and the rules around it. A conflict asks exactly what a paste asks and the prompt carries the picked folder. One thing needed care: the reload that follows every operation clears the selection, and it finishes after the operation returns because it is a coroutine of its own. The restore therefore happens at the end of that reload, through a field the transfer sets - otherwise the last writer wins and a copy silently loses the selection it promised to keep, which is exactly what the first version did until the test caught it. Seven view-model tests (copy, move, folders-only listing, the walk up and down, the conflict target, cancellation, the selection rule) and one Compose test for the overflow menu. CHANGELOG and ROADMAP record it, the roadmap naming what is deliberately not done: the copied-to-X message with a jump to the destination. |
||
|
|
cb47090e11 |
Draw a large folder while it is being read, instead of after
Two seconds of readdir and stat cannot be made much cheaper - each is a round trip to another process - so the folder is now read in batches and drawn as it arrives. StorageSource.listBatches hands over 200 entries at a time; AllFilesSource implements it with Files.newDirectoryStream, which is lazy where File.listFiles materialised the whole directory first; the default implementation is one batch, so the SAF backend and every test fake are unchanged. BrowserViewModel.load merges each batch into the list already on screen with core/SortedMerge, which is O(n+k) rather than re-sorting the folder on every batch, and keeps the rows in the user's order at every moment instead of shuffling them into place when the last batch lands. loading stays true until the last batch, so the progress bar the screen already draws sits above rows that are already useful. publishEntries is separate from recompute for that reason: the merge has done the ordering, and the batch path only has to apply the hidden filter. Tests: four for the merge (including ties not jumping ahead) and one for the batched read, which asserts 250 files arrive as 100+100+50 covering every entry exactly once - the thing a streaming read is easiest to get wrong about. PERFORMANCE.md records the expected effect and states plainly that it is not measured yet, since confirming it needs the 20000-file fixture rebuilt. |
||
|
|
55b7c2d36d |
Correct the record: the sort was never the stall
The last commit said the sort explained the 598-frame outlier at 20000 files. A measurement says otherwise: sorting 20000 entries on the JVM takes 3-23 ms, three orders of magnitude short of a ten second iteration. The sort was moved off the main thread on a story that fitted the code instead of the data, and PERFORMANCE.md now says so in those words rather than quietly keeping the claim. Where the cost actually is, from the ladder itself: 1000 to 5000 files added 27 frames (about 0.45 s) and 5000 to 20000 added 143 (about 2.4 s), which is ~0.1 ms per file with nothing superlinear - readdir plus a stat per entry, on the IO thread but on the critical path to the first row. The outlier is therefore most likely GC or scheduling on top of that, and the honest next step is a perfetto trace of one of those iterations, not another theory. The off-main sort stays, described as a precaution: 3-23 ms is cheap, but it is work the frame has no reason to wait for. |
||
|
|
e5c1ab10a7 |
Take the sort of a large folder off the main thread
The 20000-file measurement had a shape worth following up: median 184 frames, but one iteration of five at 598. Reading the listing path found the reason it could be that shape. AllFilesSource.list already hops to Dispatchers.IO, but BrowserViewModel.recompute filtered and sorted whatever it returned on the caller's thread - and the default order is natural order, which parses the digits in every name on every comparison, so 20000 rows is a few hundred thousand string comparisons plus a fresh 20000-element list inside a tap. recompute now sorts off the main thread above 500 rows and keeps the synchronous path below it, so the common case is untouched and the tests that read state immediately after an action still hold. The dispatcher is injected for exactly that reason, and the large path has its own test: 600 files, listed, in natural order. The state update is guarded on path, sort and hidden-files so a second tap cannot be overwritten by the answer to the first. The fix is not re-measured: the 20000-file fixture takes half an hour to build through MIUI's storage layer, so PERFORMANCE.md keeps the 598 outlier as the motivation and says plainly that whoever has the fixture next should confirm the maximum came down. |
||
|
|
1371407435 |
Harden the thumbnail path: versioned cache keys, the icon as the placeholder, and a decided cache
Three things, each a failure the thumbnail path could have caused. First, correctness: Coil keys caches by the request, and a request built from a File is keyed by its path, so a picture replaced or edited outside the app kept showing its previous self - in a file manager that reads as data loss. core/ThumbnailKey now carries path, size and modification time, is built from the numbers the listing already has (no stat, so it stays off the main thread's disk work), and has four tests that name the two ways a file changes. The request carries it as the memory and disk cache key. Second, the placeholder: the row drew an empty square while a thumbnail decoded, which a thousand-row folder makes very visible. The kind icon is now drawn first and the picture paints over it, so every row shows what it is immediately and the thumbnail replaces it without a flicker. Third, the cache is decided rather than inherited: memory only, at 15% of the heap, and no disk cache at all - the sources are local files that decode cheaply, and a disk copy is a second copy that survives the file changing. Coil's default would have kept up to 2% of free space of stale thumbnails. Measured on the phone and recorded in EDGE_CASE_TESTING: a 6200-file folder holding 300 real JPEGs opened and scrolled five times with no crash, no OOM and text rows keeping their icons (PSS 86.6 MB), and a folder of 300 real JPEGs where every row drew its own picture and eight swipes down and one back left PSS at 149.6 MB with the cache full and stable. Stated as not measured: jank on this screen (this device reports no frame durations) and a 20000-image folder. |
||
|
|
4846454645 |
Ship a baseline profile, generated on the emulator because the phone cannot collect one
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. |
||
|
|
14615f9842 |
Take the two main-thread disk reads off the startup path, and report what that bought
Both costs StrictMode found on a cold start are now off the main thread. scratchRoot is a Lazy<File>, so Context.getCacheDir - which checks and creates the private cache directory, measured at 492 ms - runs when a preview first copies something instead of while the view model is being created. The free-space probes moved into fillRootFreeSpace and fillFreeSpace, which run on Dispatchers.IO after the rows are already on screen and update the state when the filesystem answers; nothing waits for them, and fillFreeSpace checks the path is still the one on screen so a fast double-tap cannot leave a folder showing another one's number. Measured after: StrictMode no longer reports either violation, and the only ones left are MIUI's own font hook - FontSettings.loadFontSetting, TypefaceUtils.loadFontSettings, InterceptorProxy.create via MainActivity.onCreate, in 50-78 ms pairs. The debug cold start went from a 1814 ms median to 1803 ms and the benchmark coldStart from 519.5/531.6 ms to 485.6/568.9/600.0 ms. That is no improvement, and PERFORMANCE.md says so in those words: both violations were first-call costs (cache directory creation once per install, a cold StatFs page cache), the timed runs are warm, and the device's run-to-run spread of 115 ms is wider than anything the change could have bought. What it does change is the launch right after an install, which is the benchmark's first iteration: 648 ms max fresh against 550 ms warm. |
||
|
|
ec76acfdd6 |
Make the benchmark actually measure the benchmark build, and record what the phone will and will not answer
The run was installing app-debug.apk the whole time, which would have published debug numbers as the release-like ones. AGP 9's com.android.test extension has no testBuildType and no targetVariant - it exposes only targetProjectPath - and the target variant is matched by name against the test module's own build types. So the module now declares a benchmark build type, and inherits the debug signing config with initWith: a declared type starts unsigned, and an unsigned test APK fails with INSTALL_PARSE_FAILED_NO_CERTIFICATES. The command is now :benchmark:connectedBenchmarkAndroidTest, and -PbenchmarkBuild is gone from the workflow; the conditional testBuildType it used to set in :app only ever moved the unit-test variant, which is why it made :app:testDebugUnitTest disappear. The storage grant also had to move into the test. The rule uninstalls and reinstalls the app before measuring, an uninstall clears app-ops, and appops set from inside the test does not work even under adoptShellPermissionIdentity - the command fails to stderr, which executeShellCommand does not return, and appops get still reads No operations. What does work is tapping what a user taps: Grant full access, then the platform consent, then back out of the all-files page. grantStorageAccess does that in setupBlock, unmeasured, and it is what turned three failing tests into three passing ones. The search test now waits for the app bar's search action in setup instead of looking it up immediately inside the measured block, where it came back null one frame after a thousand rows rendered. What the device answers: coldStart passes at 560.6 ms P50 (a third measurement of the 532 ms figure), scroll passes but reports only frameCount and no frame durations at all, open dies on INSTALL_FAILED_USER_RESTRICTED because the rule's reinstall wants a human, and search still cannot see the search action. PERFORMANCE.md now states that per benchmark instead of the earlier guess, names the four switches that make an unattended run possible, and says that three of the four numbers need a device that installs unattended and reports frame timing. |
||
|
|
cb2db9ab63 |
Make the benchmark build the app itself, and record what the runner does to it
A separate package id for the app under test is the tidier design and cannot be used on this device: a new package id needs a human to approve the install every time, and installing by tapping the APK left the package listed by pm list packages yet unknown to pm enable and invisible to PackageManager. The benchmark build type therefore keeps the app's own id and takes the release key, so it updates an installed release build and keeps its data. The trade-off is stated where it happens: this build replaces a release install, and reinstalling the release APK puts it back. Two bugs surfaced on the way and both are now commented at the line they belong to. -PbenchmarkBuild was read with providers.gradleProperty, which reported it absent, so the runner installed the debug APK and failed on it; hasProperty is what works. And the reason am instrument cannot be used for this module is now recorded: MacrobenchmarkRule uninstalls the target at the start of a run and reinstalls it from the APK paths the Gradle runner passes in, so without those the app is uninstalled and left that way - which is what produced NameNotFoundException for an app that was installed a second earlier, twice, with Benchmark: Uninstalling in logcat to prove it. coldStart keeps its measured number (532 ms median). The three interaction benchmarks remain unmeasured for a reason that is now written down: each run needs one tap on the phone for the reinstall MIUI gates. Gate green, map current. |
||
|
|
1f94d0a38a |
Add a macrobenchmark module, and find out what the build was hiding
docs/PERFORMANCE.md has been honest that three of its four numbers do not exist. They exist now as measurements, if not yet as results: benchmark/ is a macrobenchmark module with four tests - cold start with StartupTimingMetric, then opening, scrolling and searching a folder of a thousand files with FrameTimingMetric - sharing a fixture at /sdcard/AABenchmark that is created once per run and deleted in @AfterClass. Frames rather than a named trace section on purpose: what a user calls slow is jank on screen, and the alternative puts benchmarking calls into production code for the sake of a number. CompilationMode.None() is the honest cold-install baseline; Partial() plus a baseline profile is the follow-up and needs the same module. The storage grant is applied with appops in setup, which is what lets the run be unattended - a permission dialog is the one thing a test cannot answer. Three build facts had to be discovered rather than designed, and each is now written where it will be read. The app under test cannot keep its own package id: it needs a debug signature to install on a test device, so it cannot replace a release install, and an un-suffixed build is exactly that. It also cannot use the obvious suffix: `.benchmark` is already the benchmark module's own APK id, and the test APK then fails to install with INSTALL_FAILED_VERSION_DOWNGRADE - which is what the first attempt did. Hence `.perf`. And `testBuildType = "benchmark"` cannot be set unconditionally, because it makes benchmark the variant that *unit* tests run against and :app:testDebugUnitTest - the task the entire gate is built on - stops existing; it is behind -PbenchmarkBuild instead. All three were measured, not guessed, and the third would have broken CI silently. Two smaller things came with it. tools/verify.sh --release now also compiles the app's benchmark variant and the test APK, because build wiring nobody runs is wiring that rots; no device is involved. And the code map grew an EXTRA_ROOTS section so it indexes benchmark/ too - a map that silently omits a module lies by omission, which is worse than the one line it costs. The numbers themselves are still missing, and for a device reason rather than a code one: installing the new package id on this MIUI phone fails with INSTALL_FAILED_USER_RESTRICTED (the "Install via USB" gate, which wants a human and answered too late). Both APKs are pushed to /sdcard/Download/ for a manual install, and the run is one command away: `adb shell am instrument -w -e class de.softwareapp_hb.filemanager.benchmark.BrowserBenchmark de.softwareapp_hb.filemanager.benchmark/androidx.test.runner.AndroidJUnitRunner`. Gate green with --release, including the new compile step; map current; 386 tests unchanged. |
||
|
|
0bbee25ab3 |
Test the interface as a user sees it, and let a debug build tell on itself
Fifteen rendering tests across four files are the first tests in this project that compose a screen, and they cover the claims that used to be prose only: the app bar's star exists where there is a folder to star and says Add or Remove according to the state, the selection bar offers Rename to several files and Details only to one, the batch dialog spells out the names it will produce and refuses a collision with the file named, the shared name dialog refuses a slash while it is typed, and a favourite that has gone says "Folder not found" rather than showing a path that is not there. They run on the JVM through Robolectric - the Compose test artifacts plus the test manifest - so they belong to the same verify.sh gate as everything else instead of needing a device: 386 tests in total. Writing them turned up one behaviour worth recording. The batch dialog continues the folder's series by default, so a collision cannot be shown unless the start number is forced back to 1 - which is precisely why the feature works in practice, and now also why two of these tests set the field explicitly. The debug build now enables StrictMode (disk reads and writes, network, leaked closables; logged, not fatal) and attaches LeakCanary. A file manager is the worst possible app for a blocking disk read on the main thread, and a debug build is the last place where noticing it is still cheap. Logging rather than a crash is deliberate: turning a slow screen into a crash on someone's phone is the worse trade. The flag is read from the manifest, not from BuildConfig, which this build does not generate. Performance has a number for the first time, and it immediately paid for itself. tools/measure-startup.sh takes cold start with `am start -W` - median of several runs, one warm-up discarded because the first launch after an install pays for dexopt - and docs/PERFORMANCE.md records 1814 ms over five runs, in the debug build, which is the slow case by construction. StrictMode's first run then produced five violations against that number: 492 ms resolving `app.cacheDir` inside the view-model factory, 397 ms reading free space with StatFs for every volume inside refresh(), 477 ms of the platform loading its own typefaces, and two 54 ms OEM font hooks. That is about 1.5 s of main-thread disk I/O in a 1.8 s start, of which ~0.9 s is ours and mechanical to move. Neither of ours is fixed here on purpose: the numbers and the two fixes are recorded in PERFORMANCE.md, and they are the first items of the speed work. What is not measured is stated in the same file rather than left to be assumed: folder open at 1 000 / 5 000 / 20 000 files, thumbnail paint while scrolling, and keystroke-to-result all need a `:benchmark` module and a profileable build under the release package id, and this device waits for a confirmation dialog on the first install of a new package id. The module is deliberately absent instead of half-configured: build wiring nobody can run is worse than a recorded gap. Gate green (map current, detekt, 386 tests, lint), and CHANGELOG, README, AGENTS.md, ROADMAP, PERFORMANCE.md, EDGE_CASE_TESTING and the regenerated map carry it. |
||
|
|
b1bc805af0 |
Make the gate one command, and add static analysis behind a baseline
Everything this repository promised was voluntary until now: 371 tests, lint and the generated code map ran only if whoever changed the code remembered to run them. tools/verify.sh is that gate as one command - the map check, detekt, the unit tests and lint, cheapest first so a stale document fails in a second rather than after a release build - and --release adds the R8 build. The ordering is not cosmetic: the two cheapest checks are also the two that catch drift nothing else can see. The same script runs in three places: CI on every push and pull request, a nightly --release job (R8 is the one check nothing else performs, and it has hidden a real defect here once), and a pre-push hook once the clone has been told to use .githooks. The nightly also uploads the R8 mapping, which is what turns a release stack trace back into a line of Kotlin and is only worth keeping if it belongs to the build that shipped. Detekt is adopted with a baseline, which is the entire adoption strategy: everything it finds today is frozen in config/detekt/baseline.xml, so the gate fails on a new finding rather than on a backlog nobody is going to clear this week. Entries are keyed by rule, file and enclosing declaration, so a new long function is still reported inside a file that already has one - proved by writing a TODO into a file, watching the build fail on that exact line, and taking it out again. What it found first agrees with the churn numbers from the last pass: ArchiveEngine 16 findings, BrowserViewModel 11, SelectionBar 9, 86 in total over 13 rules, the largest groups being the complexity ones this was added for (cyclomatic complexity 10, nested block depth 7, too many functions 4, long methods 3, two classes over 700 lines) plus 30 over-long lines. Three findings were fixed rather than parked, because they were real: dead `menuOpen` state left in BrowserScreen when the overflow menu moved into BrowserTopBar, a missing trailing newline, and two `db` parameters that are the platform's signature rather than an oversight (suppressed with that reason written next to them). Four rules were configured rather than obeyed: PackageNaming, because the applicationId's underscore is an identity this app cannot change; FunctionNaming, for @Composable (PascalCase by convention) and for backticked test names, which is the project's specification style; MagicNumber, because sizes and limits are named constants here; and MatchingDeclarationName, because a file is named after its concept rather than after a declaration inside it. Two tooling facts cost real time and are in neither tool's documentation. Detekt runs through its CLI rather than its Gradle plugin: the plugin performs the analysis inside the build's own JVM, and detekt 1.23 cannot parse JDK 25's version string - it fails with "25.0.4.1" as the entire error message - so the task drives detekt-cli on a 21 launcher, which is also the only way to pin the JVM the analyser runs on. And a baseline has to exist before it can be read, which is why it starts as an empty document rather than being generated into place. Verified: the fast gate is green in 17 seconds, --release adds a 36-second R8 build, 371 JVM tests still pass, lint is clean and the map is current. CHANGELOG, README, AGENTS.md, ROADMAP (the gate is now a recorded decision) and the regenerated map carry it. |
||
|
|
0743bda29e |
Let a folder be starred, and put the starred ones on the home screen
The app bar of any folder now carries a star, and a starred folder appears in a Favourites section on the home screen, above the libraries. Above, because it is the user's own list - and absent entirely while empty, so someone opening the app for the first time still meets the libraries first, which is the order they were given for a reason. The star in the row is the removal, which is what a star means everywhere else: the mark that says "this is a favourite" is the thing you tap to stop it being one. That avoids a long-press as well, and with it a destructive gesture nobody can see. A favourite is a reference, not a copy: data/BookmarkStore keeps one path per row and nothing about the folder's contents, so reading the whole list is one query and no filesystem work, which is what makes it affordable on every visit to the home screen. It has its own SQLite database rather than a table in the trash index - one table and three queries do not justify Room in a build that has no annotation processor, and the two stores have nothing to say to each other. Two behaviours there are decisions rather than defaults. A folder that has gone is kept and marked (Folder not found) instead of being deleted: a card out of its slot and a folder deleted in another app are indistinguishable from here, and silently forgetting someone's list because their SD card was out is the worse of the two mistakes. And opening the row re-checks existence at that moment, because the flag is as old as the screen - so the answer is a message rather than an empty listing, which would read as an empty folder instead of a missing one. Two smaller refusals came out of the same question. There is no star in a library or on the root screen, because neither is a folder and there would be nothing to come back to: the distinction that governs the new-folder button, and one the view model enforces too rather than trusting a stale screen. And a bookmark has no undo, because Undo means "the last change to the filesystem the app made" and a favourite is not one - the star is one tap to put back. The row's label comes from the same place the breadcrumbs do, which is why a starred volume root reads as "Internal storage" rather than as "0". Sixteen tests: nine against real SQLite through Robolectric (round trip, ordering, starring twice neither duplicating nor moving a row, and the same no-drop migration guard the trash index carries), and seven through the view model (toggling, outliving the view model that added it, newest first, the missing folder kept and marked, the missing folder refusing to open, and a library refusing to be starred). 371 JVM tests, lint clean. Verified on the phone: the star appears only inside a folder and flips from "Add to favourites" to "Remove from favourites" without leaving the screen; the home screen lists the volume root above the libraries with its own label and its path underneath; the row opens it with the star already filled; and the row's star takes the whole section away with a snackbar. The documentation moved with it - CHANGELOG, ROADMAP (M15), README, COMPETITIVE_ANALYSIS (that gap closes instead of joining the candidates), EDGE_CASE_TESTING, and the generated code map. |
||
|
|
ddae70a8bc |
Turn the project's rules into tests, and generate a map of where everything lives
The first question about any change is which file it belongs in, and the three documents that
kept being asked to answer it - README (the tour), ROADMAP (what is done) and CHANGELOG (why it is
like that) - were never going to. docs/CODE_MAP.md answers it: all 74 source files with the purpose
taken from the KDoc already sitting next to them, their size, and the tests that cover them,
followed by every test name in the suite. Those names are sentences about behaviour in this project
("a batch rename is a single undo step"), so the index doubles as the specification, and it is the
cheapest way for a reader, human or otherwise, to find out whether something is already covered.
The map is generated by tools/make-code-map.py rather than written, because a hand-maintained
description of 74 files is wrong within a milestone and nobody notices. The script harvests
declarations and comments; --check fails the moment the file stops matching the tree, and it caught
this very change (350 tests to 355); and the file says so about itself in its first line. A file is
described by the KDoc of the declaration that shares its name, which is the convention this
codebase already follows, and where no declaration matches, the first KDoc is used and the file is
listed at the end - so 15 fallbacks out of 74 are visible rather than assumed. Test files are
attached by the class they declare (NameTest -> Name.kt), which is a name-based match and therefore
approximate: the one honest weakness, and worth knowing before trusting a row that says a file has
no tests.
AGENTS.md is the one page that points at the map: what the app is, the two files to read before the
source, the exact commands, the layout with its dependency direction (ui -> domain -> data -> core,
with core free of Android), the rules that have tests behind them, what to read on demand and what
not to read at all, the definition of done, and the traps that have cost real time here - MIUI's two
install gates, assembleDebug racing an adb install, R8 stripping what only a resource names, and adb
input text not delivering a slash.
The same pass made four of the project's stated rules fail instead of merely being stated, in
ProjectInvariantsTest, which reads the source tree: core/ imports no Android (the engine stays
testable without Robolectric), no Text(...) in ui/ is handed a string literal (text comes from
strings.xml, which the pseudo-locale build exercises), the manifest requests exactly the six
permissions the app is built around and never INTERNET, and separator handling lives in core/ alone.
Three of the four were already true. The fourth found a bug: createFolder checked only that the name
was not empty, so "notes/final" was resolved by StoragePath into the notes folder that happened to
exist - the dialog offered one folder in the folder on screen and the app created a different one.
Rename refused the same input, because the rule has exactly one implementation and only one of its
two callers used it.
Both callers now go through isUsableName, and the shared name dialog says why a name is refused -
"A name cannot contain a slash", while it is typed, with its button disabled - rather than appearing
to do nothing when it is pressed. The batch dialog and both single-name dialogs read the same
string, so the rule reads the same wherever a name is entered. Verified on the phone: a slash typed
into New folder shows the note with Create the only disabled node in the dump, and deleting the
slash clears the note and re-enables it.
355 JVM tests (5 added: the four invariants and the folder-name refusal), lint clean, the map
current. The source-level rules state their own limits in their comments - the literal rule catches
a literal as the whole text of a Text(...) and not one concatenated into resource text, because
seeing that would take a Kotlin parser, and a rule that pretends otherwise is worse than one that
says where it stops.
|
||
|
|
1220aa6679 |
Rename a whole batch at once, numbered on from the folder's own series
Selecting more than one file and choosing Rename now opens a pattern dialog rather than the
single-file one: a name, the number to start at, and a live preview of the names it will produce.
The number is the part that makes the feature worth having. A batch rename that always starts at 1
is useless the second time it is used, because the first batch already owns 1 to 12, so the dialog
reads the names already in the folder and starts at the highest "base (n)" it finds: a folder
holding "series (1)" and "series (2)" makes the next batch start at 3, which is the market survey's
most-helpful complaint answered exactly ("if I later find another group of photos which I want to
continue the batch naming with 'Ice (3)...'"). Three rules in core/BatchRename are decisions rather
than defaults, and are written down as such: the extension is kept per file, so a mixed batch of
.jpg and .png keeps each file's own; the series is counted across extensions, because "Holiday
(9).png" making the next number 10 is one series while "Holiday (10).jpg" beside "Holiday (1).png"
is two; and it continues from the highest number rather than from the first gap, because someone
renaming a second batch is carrying a series on rather than filling in the holes of one, with the
start number theirs to type where a gap matters more.
The batch is planned in full before a single file moves, and the plan has three answers and
deliberately no fourth: Ready, Blocked (naming the target that is already in the folder) or
NothingToDo. A batch that renamed nine files of twelve and then stopped would leave the user to work
out which nine and to guess whether the last three crashed or were skipped, so a collision refuses
the whole batch, the dialog names the file in the way and disables its button as the name is typed,
and a chain is declined outright - "a.txt" taking "a (1).txt" while "a (1).txt" is itself in the
batch would need the files shuffled through temporary names. Every rename still goes through the
same FileOperations.rename a single rename uses, so the refusal to overwrite anything is the same
one, and the successful renames are collected into the single Undo.Relocate step that already
existed: a feature that touches twelve files at once owes the user one tap back. The action bar now
offers Rename to any selection, since which of the two dialogs a tap means is described by the
dialog that opens rather than by the bar; Details stays single, because "the details" of twelve
files is not a screen.
Writing it turned up a quieter bug in the rename that was already there. rename(path,
"notes/final.txt") built a child path, which resolves into the subfolder rather than alongside the
file, so a name containing a separator moved the file instead of renaming it and nothing in the
dialog or the listing showed that anything had happened. Both rename paths now refuse a name that is
not one path segment (core/FileNames.isUsableName), and the dialog says which rule it is while it is
typed, which is where the user can still do something about it.
Twenty new core tests (BatchRenameTest, FileNamesTest) and six through the view model, 350 JVM
tests in total, lint clean. Verified by driving it on the phone against a folder that already held
"series (1).txt" and "series (2).txt": with three files selected and "series" typed in, Start at
moved to 3 on its own and the preview listed "series (3).txt, series (4).txt, series (5).txt";
afterwards the folder held all five series files, each file's contents having travelled with its
rename and the two originals untouched, and the snackbar's Undo put a later batch of three back in
one step. The collision and the slash were driven the same way - the file in the way named with the
button disabled, and "A name cannot contain a slash" - and the scratch folder was removed from the
device afterwards. CHANGELOG, ROADMAP (M14), COMPETITIVE_ANALYSIS (the gap moves from candidate to
closed), STORE_LISTING and EDGE_CASE_TESTING carry the feature, its decisions and the pass itself;
two tooling notes went into the last of them, that "adb input text" does not deliver a slash and
that the dialog lifts once the keyboard appears.
|
||
|
|
47e48f054e |
Give a video row a frame of the video, and fix what R8 was hiding
A video row showed a film-strip icon, and a phone holds more video than anything else, so a column of identical icons said nothing about which clip was which. Images and videos now take the same path - EntryIcon hands Coil the file, and for a video the request asks for a frame - which cost exactly one dependency: io.coil-kt.coil3:coil-video, at the same version as the Coil already in use, whose own POM adds nothing that Coil did not already bring (coil-core, kotlin-stdlib, core-ktx). No ExoPlayer, no media3, and no new permission: the decoder is the platform's own MediaMetadataRetriever. Two details are decisions rather than defaults. The frame is taken one second in, because the first frame of a recording is very often black or the middle of a fade-in, and a thumbnail of a black rectangle is no more useful than the icon it replaced. And a decode that fails - an unsupported codec, or a file only pretending to be a video - settles on the icon rather than leaving an empty square, which the row remembers so it is not retried on every recomposition. The dependency also nearly shipped a feature that did not work. coil-video registers its decoder through a META-INF/services entry, and in a debug APK that is enough: the file and the class it names are both there, and frames decoded. In the release APK, R8 removes both, because a class mentioned only from a resource looks unused to it. So the feature worked in every build that had been looked at and would have failed silently in the one users install - the worst shape a bug can have, since it needs no code change to "fix" and would have been reported as "video thumbnails don't work" against a build nobody had run. The factory is now registered explicitly against an ImageLoader in FileManagerApplication, which makes it a code reference R8 cannot drop, and the release APK was installed and driven to the Videos library to confirm the frames decode there too. That also closes the gap noted two changes ago, that no ImageLoader was registered at all; its cache sizes are left to Coil deliberately, since they are derived from the device's heap and a number written here would only be a guess. The same run found a smaller thing that no test could have: opening the search field from inside a library left the scope on "this folder" - the hint read "Search this folder" and a folder toggle sat beside it - while the search in fact ran everywhere, because the view model has always known that a library is not a folder and answered from the index instead. The behaviour was right and the screen was wrong, which is the same shape as the volume-list bug fixed earlier and the same root cause: the switch-on-open asked whether this was the root list rather than whether a folder was open. It now asks for a path, and the scope toggle appears only when there is a folder to choose about, because its two settings did the same thing otherwise. Every other use of isRootView was then checked for the same conflation and none was found. One test was added for the search behaviour; the scope mismatch itself is not testable, being a disagreement between two correct pieces. 324 JVM tests, lint clean. Verified on the phone in both builds: two clips made with ffmpeg (a testsrc2 pattern and SMPTE bars) drew their own patterns as thumbnails, beside a real 4.3 MB video from the device's own storage, and the demo library's three zero-filled stand-in .mp4s fell back to the icon - which exercised the failure path with material that happened to be lying around. The release APK is 2 294 021 bytes and still declares no INTERNET. Not done: the store screenshots still show the home screen from before the library views, and the release build running on a device is in nobody's checklist - which is exactly why the R8 problem survived until now, and is the first thing worth adding. |
||
|
|
05a71f12bf |
Browse the device by what a file is, and find the ones that hold the same bytes
The root screen opened on a list of volumes, so "where are my photos?" began with knowing which folder they were in. It now opens on Libraries - Images, Videos, Audio, Documents, Archives and Recent - each row saying how many files the index knows of that kind, and each opening an ordinary listing with thumbnails, sorting, multi-select, details and the existing viewers, so a tap on a photo opens the image viewer and a tap on a PDF the PDF viewer. Downloads and New files are deliberately absent: one is a folder shortcut and the other a time window, and presenting three different things as one feature is how a listing starts lying about itself. Documents includes .txt, because a user looking for their documents means those too, and DIRECTORY, APK and OTHER belong to no library at all - a test asserts the three omissions so they stay decisions rather than oversights. A library has to ask for "every image" without reading a name, so IndexedFile gained a derived kind beside FileEntry's and the index stores a copy of it to filter on: schema v3, with a migration that rebuilds the files table and deletes the volume stamps with it, because a stamp means "the rows behind this are this current" and a rebuilt table has no rows behind it. Rebuilding rather than backfilling is legitimate only because this index is a cache of a walk that can be repeated; SqliteTrashIndex's rule is the opposite and its own documentation says why. A library then obeys the indexed search's two rules exactly: the answer comes first with the walk behind it, and every row is confirmed against the filesystem before it is shown, with the rows whose file has gone forgotten rather than displayed. On a device that has never been indexed the first tap on Images therefore starts a walk - which the screen admits to, using the progress row the search already had, instead of showing an empty library that reads as an empty device. "Find duplicates" in the overflow compares contents in three passes, cheapest first: size, which the index already knows, so the candidate list is one query and reads nothing at all; the first 64 KB of every candidate, a filter whose only power is to rule files out; and the whole file for whatever survives, which is the pass that decides. The separation is enforced by a test that builds two files sharing their first 64 KB and differing afterwards and asserts both were read in full - it would fail if the cheap pass were ever treated as an answer. The screen shows only what was measured: groups ordered by the space deleting the spare copies would give back, each header stating the copies and what they cost, every row carrying its folder as well as its size, and nothing preselected, because which copy to keep is the one decision the screen exists to hand over. A file whose bytes cannot be read is counted and reported rather than skipped, "nothing found" is never claimed without saying how many files were compared, and deleting closes the scan instead of patching it - its rows came from comparing bytes, and quietly dropping some of them would leave a list that still looks like a scan while its headers claim numbers nobody measured. The thumbnails went from 28 to 48 dp in a list row and 40 to 96 dp in a grid cell, with the grid's minimum column width raised to 144 dp to suit them; ListItem measures its leading slot rather than capping it, so a row grows to fit instead of clipping. That turned out to matter: the commonest complaint in the competitive survey is thumbnails that load only once scrolling stops, which is the failure mode a larger thumbnail invites. Two smaller corrections came out of the same work - a mutation now re-reads whatever is on screen, so a delete from inside a library removes the row instead of doing nothing at all because there was no path to reload, and returning to the app re-reads a library rather than dropping the user back to the roots. docs/COMPETITIVE_ANALYSIS.md records the market leader's features and what its users praise and complain about, with the sources that were blocked and the size of the review sample stated rather than glossed over; it is where both features came from, and the gaps that cost no permission are listed as candidates in the roadmap. 323 JVM tests (34 added across the two features), lint clean. Tested on the real phone - the same M2101K7AG, with the debug build installed past MIUI's two gates. The libraries on a cold index (no counts rather than a row of zeroes, "Indexing 300 files..." on the first tap, then "Index updated" and real thumbnails), the counts afterwards (Images 6238, Recent uncounted), a tap opening the viewer and BACK returning with the scroll position, and a duplicate scan that found four genuine groups in the device's own storage with nothing crashing. The duplicate delete was deliberately not pressed on a device that is not ours to empty, the store screenshots predate the new home screen, and the library views turn the M10 screenshot rule into a privacy question as well as a cosmetic one - all of that is recorded in docs/EDGE_CASE_TESTING.md rather than left implicit. |
||
|
|
90806fa3cc |
Search from the volume list, and type into the field without a second tap
Two papercuts in the search added in the previous commits, both found by using it. BrowserContent decided between the volume list and a folder listing on path == null alone, so on the "Storage" screen the roots were drawn over the answer: a search everywhere from there was computed and never shown. The switch now also asks whether a search is running - its results are full paths, so nothing has to be open first - and the volume list is drawn only when there is neither a folder nor a search. Opening the field on that screen also switches the scope to everywhere, since "this folder" has no meaning with no folder open and the field's hint would otherwise invite a query that could never match. The view model makes the same fallback on its own, so a query typed from the volume list is answered everywhere whether or not the scope was switched. Tapping the search action now focuses the field and brings the keyboard with it, waiting for the frame the field appears in, because focusing a node that is not attached yet does nothing. Asking the user to tap the box they just opened was a tap of pure ceremony. The wide layout no longer focuses the first root while a search is running, which would have moved the detail pane under the results. 289 JVM tests, lint clean, release APK builds. No device pass: /dev/kvm is still absent from this environment, so the emulator cannot start - the focus change in particular is verified by compile and by reading the API contract, not by watching a keyboard appear. |
||
|
|
d2183ebc79 |
Keep the search index current without re-walking the volume
A search everywhere rebuilt the whole index once it was older than fifteen minutes, so every session paid for a walk of the entire volume - tens of seconds of reading before a single result - even though almost nothing had changed. StorageSource gains names(), which lists a folder's names without reading a size and a date for every child. That is the difference between a walk and a catch-up: list() costs three stat round trips per entry on a phone's mounted storage, and a name index does not need any of them for entries it already knows. SearchIndexer.refresh() compares the names a folder holds now against the names already indexed and reads metadata only for names it has never seen, writing nothing at all for a folder that has not changed. The search itself now answers first and catches up behind the answer: whatever the index knows is shown immediately, and the pass that makes it current runs afterwards and answers again, which is the whole of "I just downloaded it, so why is it not here?". A full walk is now the exception - a volume never indexed, one whose rows were built under the other hidden-files preference, one not walked in full for a week, or the explicit Rebuild - because a catch-up compares names and so cannot see a file swapped for a different file of the same name, nor what a preference change means for rows that were never counted. Two timestamps per volume keep that distinction, which is a schema change: v2 adds full_at and hidden_included with a real migration that keeps the rows, since throwing them away would cost the next search the walk it just avoided. A folder that cannot be read now answers null rather than empty, and a catch-up leaves what it knows about it alone: concluding "everything inside has gone" from a transient failure is how an index loses things that are still there. The tests assert what a pass *reads*, which is the point of the change: a current index makes no call to the storage at all, a stale one reads only the names it has never seen, and a walk followed by a catch-up over a real temporary tree keeps the index level with files and folders added and deleted between them. AllFilesSourceTest covers the new names() against the real filesystem, including the null for an unreadable folder. 288 JVM tests, lint clean. No device pass this time: /dev/kvm disappeared from this environment partway through, so the emulator could not be started again. The device checks for the index itself were made in the previous round. |
||
|
|
ae44cdb994 |
Preview files inside an archive, and search every volume from an index
The archive preview could say what a zip held but not show any of it, and the search could only answer "is it here?" for the folder tree the user was standing in. Both are now answered. ArchiveEngine gains a single-entry reader: a ZIP the backend can seek jumps straight at the entry's data through the central directory, 7z scans its index, a streaming container decompresses past whatever is in front of the entry, and a compression wrapper has exactly one entry whose name is derived from the archive's own. extractEntry is rewritten on top of it, so there is one implementation of "reach this entry" rather than two that could disagree, and the now-dead extractFromZip and only= parameters are removed. A tap on a row now previews and the row's action extracts, which is what a row means everywhere else in this app. Text and markup are read into memory, so the commonest question - what does this readme say - writes nothing at all and works in both storage modes. Everything else needs a file a decoder can open, so the entry is copied into the app's own cache, one folder per preview and under its own name so the viewer's title bar does not read copy-0.png, and deleted when the preview closes with a non-recursive delete. The copy is a real path, so image, PDF and media previews work even in the SAF fallback, where the browsed files cannot be previewed at all. A copy has no file to save back to, so the text editor shows no Save and markup offers no editor; closing returns to the archive rather than out of it. SearchIndex is a small port over SQLite beside the trash index, inheriting that one's doctrine that an index is not the truth: a reader drops rows whose file has gone and forgets them, freshness is reported rather than assumed, and a volume's rows are replaced wholesale so an interrupted pass leaves the previous listing rather than half of this one. Timestamps are written only when a pass finishes, so a walk cut short by a new keystroke reads as older than it is, which is the safe direction to be wrong in. Names are matched with NameMatcher's semantics translated into LIKE - substring, anchored glob, and a literal % or _ escaped - because a search that quietly widens what was typed is what an index does badly. The search field gains a scope toggle, and every search everywhere carries the age of the index and a way to rebuild it, because a search that silently omits what arrived since the last walk is worse than one that spends a minute being sure. Rows are written in batches of 500 and the counter reported every 100, since a row reading "0 files" for the whole first second reads as nothing happening. Deliberately not a foreground service: a pass runs while the screen is up and stops when it is not. Verified on the API 35 phone rather than inferred: a .txt and a .png inside a real zip, both previewed from the archive list with their own names, the copy gone on close and the archive list restored behind it; then the first search building the index with a full walk, and a search after an app restart answering instantly and finding a file in Download/cline-zip while browsing Internal storage. Watching it caught two lies, both fixed here: the empty state claimed "Nothing matches" while the index was still being written, and two progress bars stacked on each other. 268 JVM tests, lint clean, release APK builds and installs. The store listing gains the archive preview and the scoped search and is re-measured at 2730/4000. |
||
|
|
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. |
||
|
|
9c39f15aa8 |
Migrate the trash index forward instead of dropping it
SqliteTrashIndex.onUpgrade emptied the table and recreated it, which was defensible only while nothing had shipped. It loses more than a cache: the staged files are still on disk, but only this table knows where they came from, so emptying it turns every staged delete into a permanent one, silently, with no error raised anywhere. onCreate and onUpgrade now delegate to a create/upgrade pair on the companion, which is internal so a test can drive it against a throwaway database. Version 1 is the first version this app has shipped, so the loop has no steps yet and DATABASE_VERSION stays 1 — the shape is the point. When a version 2 arrives the step for version 1 becomes the ALTER TABLE plus backfill, or the copy-and-swap for a change SQLite cannot make in place. A version with no step now reports itself with error() rather than wiping the table, so the failure mode of forgetting a migration is a loud message instead of quiet data loss. Two tests pin it against real SQLite: a bump to a version with no migration must report itself and leave the rows intact (the old code would have returned normally with an empty table, which is exactly what this guards), and an already current schema is a no-op. 206 JVM tests, lint clean. |
||
|
|
bb1187881b |
Make the Android 13+ pass, and fix what it found
The phone runs Android 12, so the questions that matter most for a release were exactly the ones it could not answer. Installed an API 35 AVD (sdkmanager was already present and dl.google.com reachable) rather than waiting for a public test track to report back. It found two user-visible defects, neither of which any test could have caught. On Android 13+ the app asked for no runtime permission at all, so the progress notification was dead. StorageAccessPolicy.missingPermissions returned an empty list from API 33 on the reasoning that "all files access" is a special permission and browsing therefore needs no runtime grant — true as far as browsing goes, but the early return also dropped POST_NOTIFICATIONS, and nothing else in the app requested it. startForeground still ran, so nothing crashed and all 203 tests passed, while dumpsys showed granted=false and importance=NONE. Settings could not fix that either, because Android will not let a user grant a permission the app has never asked for. The notification grant is now requested alongside the storage grant, which is the only moment Android accepts it. The four READ_MEDIA_* declarations were in the same position, and the manifest comment claiming they back thumbnails and viewers on Android 13+ was wrong. Coil loads both from a real filesystem path — the code comments already say "all-files mode only" — and the one MediaStore use is a best-effort delete request that "all files access" already covers. They were never requested, so they were never granted, so nothing could have depended on them. Removed, which also settles the checklist rule that declaring an unused permission is itself a policy violation. The selection bar drew its last three actions off the screen on a phone. With one archive selected it holds ten icon buttons, ~480 dp at the 48 dp minimum touch size against the 411 dp a phone has, and BottomAppBar is a plain Row that does not scroll. Compose laid the surplus out past the right edge: measured on a 1080 px screen the row ran Copy(167) to Compress(923-980, itself squeezed to 57 px) and stopped, so Extract here, Select all and Clear selection were unreachable, while the same selection in a 2400 px landscape window showed all ten. The logic was never wrong — ArchiveFormats.isExtractable had judged the .zip correctly — the app just looked like it could not extract. The action row now scrolls, verified by swiping it in portrait and seeing all ten appear. Verified on the AVD rather than only built: the notification dialog now appears and importance goes NONE -> DEFAULT, a real 220 MB copy posts notification 1001 on channel file-operations with ONGOING_EVENT|FOREGROUND_SERVICE and tears it down plus stops the service when done, edge-to-edge is correct under API 35s enforcement, predictive back returns from a folder and keeps the activity resumed, and Compress to ZIP produced an archive the host unzip read back with the right relative paths and contents. 204 JVM tests, lint clean, release artefacts re-measured. |
||
|
|
667a85f80c |
Remember what is in the trash, and add a screen to restore from it
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. |
||
|
|
d1bc61e717 |
Handle the Back button: close the overlay, then go up, then leave
There was no BackHandler anywhere, so Back was left entirely to the system, which finishes the activity rather than navigating up a folder or closing a viewer. Verified on the phone: Back on the roots screen still exits, and Back inside a folder now goes up and stays in the app. The handler has to sit above the overlay branch, not after it: that branch returns early, so anything below it is never composed while a viewer or the editor is open, which is precisely when Back most needs an answer. The first attempt made that mistake and the device test caught it. |
||
|
|
9a1aa9220b |
Fix the delete confirmation, which still promised permanence
Hardware testing caught the dialog saying 1 item will be permanently deleted. This cannot be undone while deletes now stage into the trash. Reworded to describe what actually happens, and deliberately without promising an undo button: that lives in a snackbar and is gone in seconds, whereas the trash keeps the item for a day. Also records that the trash has no screen of its own. |
||
|
|
cc085b43de |
Receive shares and serve file requests, so other apps can reach this one
The manifest had nothing but a LAUNCHER filter, so no other app could hand Plain Files anything. It now registers for ACTION_SEND and SEND_MULTIPLE (a bar over the browser: navigate to the folder, then Save here) and for GET_CONTENT and OPEN_DOCUMENT, where tapping a file hands it back through the existing FileProvider. Both reuse the browser instead of a second screen, because both are navigate-to-a-folder with a different verb at the end. data/ gains one port, IncomingSource, to keep a ContentResolver out of the ViewModel the way DeleteRequestFactory keeps one out of the delete path. Shared files are named from the sender OpenableColumns, allocated by the same NameAllocator a paste uses, and deleted again if they cannot be read rather than left as zero-byte stubs. While providing, a tap on a file chooses it but a tap on a folder still navigates, or the user could never reach the file. ACTION_VIEW is deliberately not registered: the app already hands files out through the system chooser, and registering as a viewer would offer Plain Files back to itself. No new permissions - the merged release manifest still declares no INTERNET. |
||
|
|
ffed22c1da |
Search the whole folder tree, not just the folder being browsed
The query box filtered the current listing, which only ever answered is-it-here; the usual question is where-did-I-put-it. domain/FileSearch walks down from the current folder and streams matches as they are found, since a whole-volume walk takes tens of seconds and a blank screen for that long reads as a hang. Hits show where they were found, and the app bar shows progress while the walk runs. Bounds, all deliberate: a matching folder is still descended into; a hidden folder is skipped whole rather than merely filtered, so the preference cannot be leaked around; the walk stops at 200 matches; each keystroke cancels the previous walk instead of queueing; and navigating away abandons the search. |
||
|
|
02780c97e5 |
Make delete, rename and move undoable
A delete is no longer a removal: it is a move into a hidden .plainfiles-trash folder on the same volume, which costs a rename and keeps the item recoverable. The snackbar offers Undo for delete, rename and a pasted cut; the handle is cleared as soon as it is used. domain/Trash carries four deliberate limits: the trash lives on the users own volume, so it counts against their free space until it expires (24 hours, purged opportunistically, no startup hook and no empty-trash chore); a name taken again in the meantime is never overwritten, so an item returns beside it; a delete through Androids own dialog is not staged because the system owns it; and each item is staged in its own slot so two files sharing a name cannot overwrite each other on the way in. When a pasted move landing names cannot be established with confidence, no undo is offered rather than risking the wrong file moving back. |
||
|
|
77acb80f17 |
Keep the browsed folder and view settings across process death
Wires SavedStateHandle into BrowserViewModel: the folder being browsed plus the sort key, direction, view mode and hidden-files setting are restored on relaunch, so a process death no longer drops the user back at the top of the tree. Deliberately excluded, and documented as such: the search query (restoring one would hide the folder contents on launch with no visible cause), the selection, and the clipboard (a paste that survived a reboot would be a nasty surprise). Four tests cover the restored state and each exclusion. |
||
|
|
ba92cf392d |
Support 7z extraction, and fix .xz extraction which would have crashed at runtime
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. |
||
|
|
55dc2293b2 |
Add a two-pane layout for large screens, and re-shoot the tablet sets
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. |
||
|
|
d845a7646e | M10: store assets - Plain Files naming, generated icon/feature graphic, listing copy | ||
|
|
bf909b3485 | M8: localisation groundwork - resource the file-kind labels, pseudo-locale check | ||
|
|
a79b7eb58e | M7: on-device validation - fix usage-screen latency and first-run copy | ||
|
|
3e39fc9b86 | M5: storage usage, ZIP/TAR/GZ archives with zip-slip guard, thumbnails, viewers + 26 tests | ||
|
|
2c86f3dc67 | M4: browser UI (navigation, sort, selection, dialogs, permission prompts, platform delete) | ||
|
|
993cac6e5b | M3: operations engine (copy/move/delete, progress, cancellation, conflicts) + foreground service | ||
|
|
0863b37d70 | M2: storage layer (all-files + SAF backends, volumes, access policy) + 32 JVM tests | ||
|
|
6d4c2c3f87 | M1: pure-Kotlin core engine (paths, classification, sorting, search, transfers) + 44 JVM tests | ||
|
|
adfd95fc38 | M0: project scaffold (Gradle 9.3.1 / AGP 9.1.0, Compose shell, storage permissions) |