Commit Graph

22 Commits

Author SHA1 Message Date
SoftwareApp 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.
2026-10-17 09:13:32 +02:00
SoftwareApp 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.
2026-10-17 07:48:26 +02:00
SoftwareApp 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.
2026-10-17 02:20:01 +02:00
SoftwareApp 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.
2026-10-17 02:06:19 +02:00
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