Commit Graph

18 Commits

Author SHA1 Message Date
SoftwareApp 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.
2026-10-17 01:40:35 +02:00
SoftwareApp 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.
2026-10-17 01:16:25 +02:00
SoftwareApp 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.
2026-10-15 10:25:37 +02:00
SoftwareApp 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.
2026-10-15 09:52:56 +02:00
SoftwareApp 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.
2026-10-15 09:18:10 +02:00
SoftwareApp 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.
2026-10-15 08:47:37 +02:00
SoftwareApp 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.
2026-10-15 00:25:09 +02:00
SoftwareApp d845a7646e M10: store assets - Plain Files naming, generated icon/feature graphic, listing copy 2026-10-14 17:47:02 +02:00
SoftwareApp e3de9a2fc0 M9: monetisation decision - one-time paid, no billing code, verified in release APK 2026-10-14 17:40:29 +02:00
SoftwareApp bf909b3485 M8: localisation groundwork - resource the file-kind labels, pseudo-locale check 2026-10-14 17:12:02 +02:00
SoftwareApp a79b7eb58e M7: on-device validation - fix usage-screen latency and first-run copy 2026-10-14 17:05:03 +02:00
SoftwareApp e2cfb5d9ca M6: release signing, privacy policy and Play release checklist 2026-10-14 16:44:58 +02:00
SoftwareApp 3e39fc9b86 M5: storage usage, ZIP/TAR/GZ archives with zip-slip guard, thumbnails, viewers + 26 tests 2026-10-14 16:29:08 +02:00
SoftwareApp 2c86f3dc67 M4: browser UI (navigation, sort, selection, dialogs, permission prompts, platform delete) 2026-10-14 16:07:08 +02:00
SoftwareApp 993cac6e5b M3: operations engine (copy/move/delete, progress, cancellation, conflicts) + foreground service 2026-10-14 15:40:48 +02:00
SoftwareApp 0863b37d70 M2: storage layer (all-files + SAF backends, volumes, access policy) + 32 JVM tests 2026-10-14 15:17:51 +02:00
SoftwareApp 6d4c2c3f87 M1: pure-Kotlin core engine (paths, classification, sorting, search, transfers) + 44 JVM tests 2026-10-14 15:05:14 +02:00
SoftwareApp adfd95fc38 M0: project scaffold (Gradle 9.3.1 / AGP 9.1.0, Compose shell, storage permissions) 2026-10-14 14:55:10 +02:00