f9305d10703187150345ff5a511594a244a5a0ba
31 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9b8a32fbd6 |
Write the release notes, the permission answers, and the video
Play asks three separate things before a file manager may keep All files access: the What's new copy, a justification for why SAF and MediaStore cannot carry it, and a short video of the permission in use. Writing them exposed two stale claims and one privacy problem, so those are fixed here rather than repeated. - docs/RELEASE_NOTES.md holds the Console field at 450/500 characters, measured, and the longer announcement. The first release's copy leads with the claim no competitor makes (no ads, no INTERNET permission) and then proves the app is still a file manager. - docs/PLAY_RELEASE_CHECKLIST.md section 5 holds the declaration answer at 490/500 characters. It argues from what the Storage Access Framework and MediaStore cannot do - one picked tree, no volume enumeration or whole-device hierarchy, no arbitrary non-media files - and never from performance, which Google's wording rejects. Both APIs are named as already used where they can be, so the argument is not that they are useless. - docs/PERMISSION_VIDEO.md and store-assets/video hold a 19.7 s captioned video recorded on the release build. The home-screen segment is cut because its library counts are the owner's real totals; the published copy resumes on the internal-storage listing. Also recorded: the emulator captures one frame in three seconds, and only appops set --uid ... deny brings the prompt back after Settings has granted the app-op. - README and ROADMAP caught up with the build: the status stopped at M15, and the trash limitation still said there was no Empty trash and that expiry waited for the next delete, both changed in M19. Verified: ./tools/verify.sh green, the code map current, the Play field measured at 450 characters and the form answer at 490, and the video decoded as H.264 1080x2400, 19.666 s, no audio, with frames checked at the cut and the end state. |
||
|
|
5d39f4b3fb |
Four milestones from the survey's remaining gaps
M17 - the mark of a change. A dot on the rows an operation produced, spent by the next read of that folder: in memory, per folder, no timer and no watcher. Copy, move, compress, extract, rename (single and batch) and new folders all mark what they made, so "what did that 4 GB copy do?" has an answer even when the copy went to a folder the user was not looking at. The dot is drawn by MarkOfChange in the list and the grid, with a content description, and `changed` is set by the same state update that announces the folder. M18 - store-only archives. Compressing now asks for the name and whether to compress, because the survey's most enthusiastic review is a bulk copy to a computer and that file is better stored than squeezed. Deflater.NO_COMPRESSION keeps it one pass; a STORED entry would need size and CRC before the header, i.e. reading every file twice. M19 - the trash says what it does. The screen states the 24-hour window, offers Empty trash behind one question, and opening the trash sweeps what has expired - the window used to run only after the next delete, so an old batch could stay on the card indefinitely. Trash.emptyAll leaves anything in that folder that is not one of ours alone. M20 - launcher shortcuts. Images, Recent and Trash as static shortcuts carrying a route extra; core/ShortcutRoute answers null for anything it does not know, so an old shortcut cannot crash a new build. Favourites was dropped from the plan: it is a section of the home screen, not a view, so a shortcut to it would promise something that may not be there. M21 (Android TV) is specified in the roadmap and deliberately not started: its acceptance needs a TV device pass this machine cannot provide. 18 new tests (427 in total). Detekt's baseline was regenerated after two signatures gained a parameter; the diff is three findings with new signatures and their KDoc moved above the annotations so the baseline stays readable. Verified by the gate only so far - the device pass for these four is next, and the terminal here has been losing command output for the last hour. |
||
|
|
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. |
||
|
|
747a6f7784 |
The queue says what the scaling numbers answered
Items 3 and 3b were done but still read as open questions, and thumbnail jank is no longer blocked on a device that cannot report it - the phone run does, and the CI emulator always will. The one item still open is the baseline profile's delta, which needs a physical API 33+ device. |
||
|
|
66183a8596 |
The benchmark runs in CI, and fails when the jank metric is missing
Item 5 of the queue: .github/workflows/benchmark.yml measures on an API 35 emulator (reactivecircus/android-emulator-runner, google_apis, x86_64, SwiftShader), on demand and every Monday. Not on pull requests: a run is minutes of measurement behind twenty of booting an emulator, and the gate is meant to answer in one or two. It installs both APKs and instruments directly instead of using connectedBenchmarkAndroidTest, because that task installs the app itself - which undoes the compile reset that makes the measurement a never-run baseline. tools/benchmark-report.py turns the JSON into two tables, keeping frameCount (a length) apart from frameDurationCpuMs and frameOverrunMs (speeds), and --require-frame-duration fails the job when the second table would be empty: the metric is the reason the job exists. Driving the workflow's own script against the phone first paid for itself three times. It found that cmd package compile --reset needs adbd as root - a user build refuses it with SecurityException, so the workflow now does adb root first (google_apis allows it) instead of silently measuring a compiled app. It produced the second run of the 20 000-file open (16 frames median, 32 max) and the first numbers for scroll, search and cold start at that size: search costs 319 frames, which is now the biggest number in the file. And it showed the phone reporting frameDurationCpuMs and frameOverrunMs after all, so PERFORMANCE.md's 'this phone reports counts only' was a property of those earlier runs, not of the handset - corrected, with the numbers. |
||
|
|
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. |
||
|
|
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. |
||
|
|
0a3ff88247 |
Record the post-1.0 queue, and close the competitor's post-paste pair as superseded
ROADMAP now carries the queue in the order it should be done - baseline profile, thumbnail hardening, the 5k/20k scaling numbers, then Copy to / Move to (M16, specified with acceptance criteria), then a CI emulator run - and states why nothing there is scheduled yet. The interesting part is the copy decision. The competitor's Copy again and Return to source folder are not deferred, they are superseded: both exist because their flow makes you leave the source folder to paste. Copy to / Move to removes that constraint at the source, so the selection is still there for the next destination and there is no back-to-source to offer. The spec records that the engine half already exists - paste() calls planTransfer(paths, target, policy) with the current folder as target and the engine is target-agnostic, so FileOpService and the conflict prompt work from anywhere - and that the missing piece is a folder picker. COMPETITIVE_ANALYSIS points at the same decision in both places it mentioned the pair, so the two documents cannot drift apart. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
a461be2a66 |
Docs: M10 complete, dual-pane no longer deferred, correct the 7z rationale
The last commit shipped the screenshots and the two-pane layout but left the roadmap and README saying otherwise. Also corrects the stated reason 7z is unsupported: RAR really does need native code, but SevenZFile is already inside the Commons Compress jar this app ships. |
||
|
|
d845a7646e | M10: store assets - Plain Files naming, generated icon/feature graphic, listing copy | ||
|
|
e3de9a2fc0 | M9: monetisation decision - one-time paid, no billing code, verified in release APK | ||
|
|
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 | ||
|
|
e2cfb5d9ca | M6: release signing, privacy policy and Play release checklist | ||
|
|
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) |