Files
file-manager/docs/EDGE_CASE_TESTING.md
SoftwareApp ddcd68b4b7
verify / gate (push) Has been cancelled
verify / release (push) Has been cancelled
Make Copy to… and Cut to… the selection bar's actions
The selection bar offered Copy and Move, which only filled an in-app clipboard; the
destination picker then lived behind an overflow menu, and Paste in the top bar. That
made a copy a two-step detour through another folder - the thing M16's picker exists to
avoid.

- The bar's two verbs are now Copy to… and Cut to…, both opening the destination picker.
- The clipboard state, Paste here, and the overflow's duplicate Copy to… / Move to… are
  removed. The picker's cut title and confirm are Cut to… / Cut here.
- A copy still keeps the selection for a second destination; a cut clears it. Conflicts
  still ask before overwriting, naming the picked folder.
- Five clipboard tests are removed; the mark, restart and undo tests now use the picker.
  BrowserTopBar's search and overflow sections are extracted so its two new detekt
  findings are fixed rather than baselined.

Driven on the Xiaomi phone: Copy to… copied 120 x 16 MB through Internal storage ->
AADemo -> Target and reported Copied 121 items to Target; Cut to… moved file001.bin and
offered Undo; a name in the way raised Items already exist here; the top-bar overflow
has no Paste. The foreground-service video was re-recorded against the new bar at 16.1 s.

Verified: ./tools/verify.sh green, the code map current, 426 unit tests pass, and the
video decodes as H.264 1080x2400 with no audio.
2026-10-18 15:07:35 +02:00

32 KiB
Raw Permalink Blame History

On-device testing

What has actually been run on hardware, what it found, and what is still unverified. Keep this file honest: an untested claim is a bug waiting to be reported.

Devices used

Device Android Notes
Xiaomi M2101K7AG (Redmi, sunny), MIUI V140 12 (API 31), arm64-v8a A real phone with a real 55 GB library. Auto-rotate was on, so runs happened in both orientations.
AOSP phone AVD 12 (API 31), x86_64 Software emulation — the host exposes no /dev/kvm. Used from M0–M6.
AOSP phone35 AVD 15 (API 35), x86_64 Software emulation, same reason. Added for the Android 13+ surface the phone cannot reach: the permission model, mandatory edge-to-edge, predictive back.

Connecting a phone to this dev container needs one fix: the USB node is created root:plugdev 0660, and the container's user is not in plugdev, so adb cannot open it. An udev rule widening the mode for Android vendors fixes it (/etc/udev/rules.d/51-android.rules), after which adb devices sees the phone.

Verified on the real device

What Result
First run, no permission granted The "Choose a folder" prompt appears with both routes offered: Grant full access and Choose folder. Correct — the app has no source yet, so the SAF fallback is the honest state.
Granting "All files access" while the app is alive The app picks it up on resume and switches from the prompt to the roots view, showing "Internal storage · 50.0 GB free" (matching df). The lifecycle refresh works.
Browsing real storage Real folders listed alphabetically with correct icons and breadcrumbs ("Internal storage / Download"). Titles render "Internal storage", not the raw volume name.
Text editor, read Tapping a.txt opened the editor showing its real content.
Text editor, write Editing and tapping Save persisted to the device — verified independently with adb shell cat.
Storage usage DCIM 2.7 GB / 668 files, Android 1.2 GB / 5925 files, Music 1.2 GB / 43 files, Pictures 866.4 MB / 5849 files, … sorted largest first with per-row share bars and correct plurals.
Revoking "All files access" mid-use Returned to the folder prompt without crashing; the app kept running. Graceful degradation confirmed.
Landscape The whole UI adapts (the phone auto-rotated mid-session): app bar, list, breadcrumbs and dialogs all laid out correctly.
BACK from the roots view Exits the app, which is the expected Android behaviour.

Bugs this session found — both fixed

  1. The first-run copy was wrong. The prompt read "Full access was declined, so pick a folder…" on a device where the user had never declined anything. Reworded to "Without full access you can still work inside one folder you choose here.", which is true whether the user declined or simply has not granted yet. Re-verified on device.

  2. The storage-usage screen took ~45–60 seconds to show anything. Measuring the root of a real 55 GB volume blocked on the whole walk with only a spinner for company, which reads as a hang. Two changes:

    • UsageAnalysis now passes the FileEntry down the recursion instead of re-stating every node (the parent's listing already says whether each child is a folder), so the walk costs one listing per directory and nothing more.
    • Rows are published as they are measured through an onProgress callback, and the ViewModel appends them, re-sorting as it goes, so the biggest folders appear while the rest of the tree is still being walked.

    The UI never froze — the progress bar kept animating throughout, which confirms the I/O stays off the main thread — but "slow and silent" is still a defect.

The Android 15 (API 35) session

The phone is on Android 12, and the checks that matter most for a release are precisely the ones Android 12 cannot answer. An API 35 AVD was installed for that reason (sdkmanager --install 'system-images;android-35;default;x86_64', AVD on the pixel_6 profile, 1080×2400 @ 420dpi — the phone's geometry), rather than waiting for a public test track to report back.

What Result
Cold start and browse Fine. Storage root, then "Internal storage / Download", with the real file and size shown.
Edge-to-edge, which API 35 enforces Correct. The status and navigation bars draw over the app background and the app bar sits below the status bar, so the Scaffold insets are being applied. This was the single most likely Android 15 regression.
Predictive back Correct. BACK from inside a folder returns to the root and the activity stays resumed, so the BackHandler composed after the overlay branch works under the OnBackInvoked dispatcher too.
Permission request Broke, fixed. On the shipped build Android 13+ was asked for nothing at all; see below.
The progress notification Fixed, then verified. After the fix, a real 220 MB copy posts notification 1001 on channel file-operations with ONGOING_EVENT|NO_CLEAR|FOREGROUND_SERVICE and category=progress, then removes it and stops the service when the copy finishes. Before the fix the app's notification importance was NONE.
Copy to completion The staged copy landed at /sdcard/bigfile.bin, byte-count identical, via the then-current "Paste here" (the clipboard flow that has since become Copy to… / Cut to…).
Compress and extract from the UI Found a bug, fixed. Long-pressing a folder and choosing "Compress to ZIP" produced a tocompress.zip that the host's unzip read back with the right relative paths and contents. Selecting that .zip then exposed the selection bar clipping its own actions — see below.
One archive of every supported format Found a bug, fixed. A real .zip, .7z, .tar, .tar.gz, .tar.bz2, .txt.xz, .txt.gz and .txt.bz2 were built on the host and all eight extracted through the UI. Each landed with the right entry names and byte-for-byte content — except .tar.bz2, which produced a bare .tar, recorded below.
Long-press from adb input swipe x y x y 700 on a single point did open the selection bar and reach Copy. The same gesture has been flaky on the MIUI phone, so it still deserves a manual pass.

The bugs this session found

On Android 13+ the app asked for no runtime permission at all, so the progress notification was dead. StorageAccessPolicy.missingPermissions returned an empty list from API 33 — deliberately, with a test asserting it — on the reasoning that "all files access" is a special permission and browsing therefore needs no runtime grant. True as far as browsing goes, but the early return also dropped POST_NOTIFICATIONS, and nothing else requested it: the manifest declared it and the service posted it, so it looked implemented, while dumpsys showed granted=false and importance=NONE. No crash and all 203 tests green — the feature was simply invisible, and unrecoverable, because Android will not let a user grant in Settings a permission the app has never asked for. The four READ_MEDIA_* declarations were in the same position, and the manifest comment claiming they backed thumbnails on Android 13+ was wrong: Coil loads thumbnails and the viewers from a real filesystem path. Fixed by removing those declarations and requesting the notification grant alongside the storage grant; a re-run on the same AVD shows the dialog, importance NONE → DEFAULT, and the notification lifecycle above. Details in CHANGELOG.

The selection bar drew three of its own actions off the screen. With a single archive selected the bar holds ten icon buttons, which at the 48 dp minimum touch size need ~480 dp against the 411 dp this window has. BottomAppBar is a plain Row and does not scroll, so the surplus was laid out past the right edge and clipped: the row measured Copy at x=167 through Compress at 923–980 — Compress already squeezed from 63 px to 57 — and then simply stopped, leaving "Extract here", "Select all" and "Clear selection" unreachable. The same selection in a 2400 px landscape window showed all ten, which isolates it to width: nothing was wrong with the logic, ArchiveFormats.isExtractable had judged the .zip correctly all along, and the app's own archive feature merely looked absent on the shape of phone most people hold. Wrapping the actions in a scrollable row makes every one of them reachable again, verified by swiping the bar in portrait and seeing all ten appear. The row still scrolls on a 411 dp phone rather than showing everything at once, which is honest but not ideal — moving the two least-used actions (Select all, Clear selection) into the overflow menu would be the better fix, and is left as deliberate follow-up rather than bundled in here.

.tar.bz2 and .tar.xz only half-extracted. ArchiveFormats.formatOf tested .tar.gz/.tgz first, as a combined format, but then matched .bz2 and .xz before it ever considered the .tar in the middle of the name — so a .tar.gz unpacked to its files while a .tar.bz2 was read as one compressed file and yielded a .tar. Driving the sweep made it exact: extracting z4.tar.bz2 left z4/z4.tar, raw ustar headers and all, beside z3.tar.gz's fully unpacked z3/a.txt and z3/sub/b.txt. TAR_BZIP2 and TAR_XZ now exist alongside TAR_GZIP, and detection consumes the whole tar suffix before the bare compressor. The suite could not have caught this: only ZIP, GZIP, XZ and 7z had round-trip tests, so neither plain TAR nor TAR_GZIP did, and there was nothing for the asymmetry to contradict. All four have one now.

The lesson is the one the phone already taught twice: a green test suite says the code does what the tests describe, not that the app does anything on a device. Both times the gap was a user-visible promise the code no longer kept — once in a dialog, once in a permission.

The library and duplicate session (M11)

Run 2026-10-17 on the same Xiaomi M2101K7AG (Android 12, MIUI), with the debug build (de.softwareapp_hb.filemanager.debug, installed with adb install). The device was cold for the index: the app had never run on it, so "the first tap on a library starts a walk" was tested as a first run rather than as a re-run. First-run permissions were granted from the host (appops set … MANAGE_EXTERNAL_STORAGE allow, pm grant … READ_EXTERNAL_STORAGE) rather than through the prompt, because the prompt was already covered in the M7 session.

What Result
Home screen, before any index exists Correct, and the important part is what is absent. The Libraries section renders above the volume with all six rows and their icons, and no counts at all — not a row of zeroes. That is the design: an index that has never run cannot say "you have no photos"
Opening a library on a cold index Correct. Title Images, no breadcrumbs, no new-folder action, an empty listing, and the honest status row: "Indexing 300 files…" with a progress bar and Rebuild beside it. The answer came first and the walk ran behind it
The walk finishing Correct. The status row became "Index updated 2026-10-17 01:01", and the library filled with real files and real thumbnails
Counts after the walk Correct. Images 6238, Videos 169, Audio 196, Documents 160, Archives 5, and Recent uncounted — the one library that is a window rather than a set, which is what the code says it should be
Tapping a file inside a library Correct. A tap opened the image viewer with that file's name in its bar, and BACK returned to the library listing with the index row and the scroll position intact
The duplicate scan Correct. Tapping Find duplicates in the overflow ran the scan and produced real groups: DOC-20251121-WA0001.zip and Schwarzhofen.zip at 10.5 MB each (identical content in Android/media/… and Download/), a PDF duplicated at 5.3 MB, another PDF present three times at 761.3 KB. Group headers read "2 copies · 10.5 MB" above "10.5 MB could be freed", each row carried its folder path, and the groups were ordered by reclaimable space
Ticking a copy Correct. The row highlighted and the bar action appeared as "Delete 1 copy" — the plural resource agrees with the count
Closing the scan Correct. X returned to the browser with nothing changed
Video thumbnails Correct. Two clips made for the purpose (ffmpeg: a testsrc2 pattern, then SMPTE bars) were pushed into the demo folder and both drew their own pattern as the row's thumbnail — a frame from inside the file, not a poster and not the icon — beside a real 4.3 MB video from the device's own storage. The library's three zero-filled stand-in .mp4s cannot be decoded and fell back to the film icon, which exercised the failure path with material that happened to be lying around
The same rows on the release APK (R8) Correct, after a fix — and this is the one that mattered. coil-video registers its decoder through a META-INF/services entry, and the release APK contained neither that file nor the class it named: frames would have decoded in debug and quietly fallen back to icons for anyone who installed the app. Registering the factory explicitly in FileManagerApplication fixed it, and the release build was installed and driven to the Videos library, where all four rows behaved as the debug ones had
The search field opened from a library Wrong, and fixed. The hint read Search this folder and a folder toggle sat beside it, while the search in fact ran everywhere — the view model has always answered from the index when there is no folder. See the findings below
Crashes None. logcat -b crash contained no entry for the package across the whole session

Four findings worth keeping

  • A ServiceLoader entry is not a contract, and R8 is the difference between a debug build and the one people install. coil-video registers its decoder with Coil through a META-INF/services file. In the debug APK both that file and the class it names are present, and video thumbnails worked. In the release APK neither was: R8 removes a class that is mentioned only from a resource, because by its reckoning nothing uses it, and the resource goes with it. The feature was therefore working in every build that had been looked at and would have failed silently in the one that ships — the worst possible shape for a bug, 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. Registering the decoder explicitly against an ImageLoader turns it into a code reference, and the release APK was then installed and driven to confirm it. The lesson generalises: audit the release build, not the debug one, and never trust a registration that happens by naming a class in a file. That lesson is now a step rather than a note — PLAY_RELEASE_CHECKLIST § 4 carries the install commands, the paths to walk, and what to do when something differs.

  • The search field lied about its own scope inside a library, and only a run could show it. Opening the field from a library left the scope on "this folder": the hint said Search this folder and offered a folder/public toggle, while the search actually answered from the index over every volume. The behaviour was right and the screen was wrong — the same shape of bug the volume-list fix had, and the same root cause: the switch-on-open asked whether this was the root list instead of whether a folder was open, and a library is not the root list but has no folder either. The toggle is now shown only when there is a folder to choose about, because its two settings did the same thing otherwise. Nothing in the suite could have caught this: it was a mismatch between two correct pieces, which is what a device pass is for.

  • The library views are device-wide, so they cannot be photographed on a real phone. The Images list showed the owner's own files — names and thumbnails included — because a library is not a folder and does not respect one. The M10 rule ("never shoot over a real device's storage") now applies to the home screen too, since its counts expose how many files of each kind the device holds. See STORE_LISTING.

  • MIUI blocks adb install twice over, and both gates are on the device, not in the tooling. Developer options needs Installieren über USB enabled, and each install then waits for a confirmation dialog (com.miui.securitycenter/…AdbInstallActivity) that times out on its own — which is what the bare INSTALL_FAILED_USER_RESTRICTED: Install canceled by user means. Worth knowing before blaming a build. A second, cheaper lesson came from this session: do not run assembleDebug and adb install in the same parallel batch. They raced, the install took the previous APK, and the new menu entry was missing while the host APK contained it — a confusing twenty minutes caused entirely by tooling, not by code.

Deliberately not done on this device

  • The duplicate delete was never pressed. The scan is read-only and safe to run on someone else's phone; deleting files is not, and the delete itself is the ordinary, already-covered delete path (trash + undo + the platform dialog where it applies). The selection UI was driven to the point of the action label and then left untouched.
  • The walk was not timed end to end on a large volume. It finished inside the 20 seconds this session waited, but that says nothing about a phone with a far larger library; the M7 note about a 45–60 s usage walk is the honest precedent.
  • The 7-inch and 10-inch tablet layouts with the new section were not re-shot or re-checked: the AVDs have no /dev/kvm here, and the two-pane root list is where the libraries now appear first.

The batch rename session (M14)

Driven on the Xiaomi phone against a purpose-made folder — /sdcard/AAABatchRename, holding alpha.txt, beta.txt, gamma.txt and an existing series series (1).txt / series (2).txt — because the one thing worth proving here is the continuation, and that needs a folder that already holds a series. Every step went through the UI with adb input, and each claim below was read back off the device rather than off the screen.

  • The dialog continues the series on its own. Three files selected (long-press, then a tap each — the M4 tap-to-toggles behaviour), Rename from the action bar, series typed into the name and the Start at field moved from 1 to 3 by itself, with the preview reading Will be renamed to series (3).txt, series (4).txt, series (5).txt. Nothing but the names in the folder was consulted, and those are the listing on screen, so the dialog and the view model cannot disagree about them.
  • The work is exactly what the dialog promised. ls afterwards showed series (1) … (5), the two originals untouched, and cat showed series (3).txt holding alpha and series (4).txt holding beta — the renames moved the files rather than copying them.
  • One undo step, through the snackbar. A second batch was renamed to clip (1..3), the snackbar read Renamed 3 files with Undo, and a single tap put all three names back, contents intact through the round trip (series (5).txt still held gamma).
  • Both refusals are visible before anything can be pressed. With series typed and the start forced back to 1 over a folder holding series (1)/series (2), the dialog read series (1).txt is already in this folder — the file in the way, named — and the only enabled="false" node in the whole dump was the Rename button. Renaming two files to the names they already hold read Every selected file already has that name instead, which is the honest version of "nothing to do", and a name containing / read A name cannot contain a slash, button disabled as well.
  • The test folder was removed afterwards. It was created for this pass, in the phone's own storage, and deleted through adb at the end: the M10 rule about not leaving artefacts on a device that is not ours applies to a scratch folder as much as to screenshots.

Two things this session added to the tooling notes:

  • adb input text does not deliver a / reliably. 'a/b' arrived in the field mangled (a!c, after the trailing character had to be deleted), so the slash case was sent as input keyevent 76 — worth knowing before concluding that a field filters separators: here the field accepted it and the dialog is what refused it.
  • The dialog lifts once the keyboard appears. Its fields sit ~200 px higher afterwards, so a second field has to be aimed at the post-keyboard layout. Two dumps were needed to tell the two positions apart, which is also why the numbers quoted above come from a dump rather than from memory.

The favourites session (M15)

Driven on the Xiaomi phone, starring the volume root — the smallest thing that can be starred, and the one where the label matters most, because its own name is 0.

  • The star is offered exactly where it means something. The app bar of the volume root shows it as Add to favourites (four actions now: star, search, sort, more); the home screen and a library show none, because neither is a folder. Tapping it flipped the description to Remove from favourites without leaving the screen, which means the state came back out of SQLite rather than out of the button.
  • The home screen puts it where it belongs. Back at the root view, a Favourites section appeared above Libraries with the volume's own label — Internal storage, not 0 — and /storage/emulated/0 underneath, which is the path the details sheet would show as well.
  • The row opens what it says. Tapping it opened the volume with the star already filled.
  • The row's star is the removal. Tapping it on the home screen took the whole section away and said Removed from favourites; the libraries moved back to the top, which is the empty case a new user sees.
  • Nothing was left behind. No files were created for this pass and the one bookmark made was removed through the UI, so the device is as it was found.

Deliberately not done on the device: a favourite whose folder disappears. The case is covered by two JVM tests, and staging it would have meant deleting a folder on someone else's phone; the missing row's wording and the message are the same strings those tests assert.

The thumbnail hardening session

The one screen that had never been pushed past what a photo album happens to contain: a folder that is mostly not pictures, next to a folder that is nothing but pictures. Both built on the phone, both driven by hand through adb.

pass result
6 200 files, 300 of them real JPEGs (AAThumbs) — opened and scrolled five times Correct and stable. No crash, no OutOfMemoryError, no decode failure that left an empty square; text rows kept their document icon and the JPEGs further down would have had their frames. PSS 86.6 MB while scrolling the text rows.
300 real JPEGs (AAImages) — opened, scrolled eight swipes down and one back Correct. Every row drew its own picture, not an icon and not a neighbour's picture — 300 copies of one photo, so a cache-key collision would have been invisible here and is covered by ThumbnailKeyTest instead. No crash, no OOM. PSS grew 83.9 MB → 149.6 MB as the memory cache filled, then stayed.

Two things that pass is not evidence of:

  • It is not a jank measurement. This device reports no frame durations at all (docs/PERFORMANCE.md), so "it scrolled" is all that can be said here. The emulator path reports frameDurationCpuMs, so the number that would matter can be taken there or on a newer phone.
  • It is not a 20 000 image folder. The 6 200-file folder held 300 images, and 300 is where the memory cache tops out. A folder of 20 000 pictures is the case that would test the cache bound rather than the listing, and it is not staged — writing 20 000 files through MIUI's storage layer took five minutes for 6 000, so it is a fixture to build once and keep, not something to improvise.

What the pass did change, before it was run:

  • thumbnails are keyed by path and size and modification time (core/ThumbnailKey), so a file that is replaced or edited outside the app re-decodes instead of showing the previous picture — the one failure a cache can cause that a user would mistake for data loss;
  • the icon is drawn under the thumbnail rather than the row showing an empty square while decoding;
  • the image loader's caches are decided explicitly: memory only, at 15 % of the heap, and no disk cache — a disk cache would hold a second copy of pictures that are already on the device, and survive the file changing.

The copy-to session (M16)

Driven on the phone through the real UI (adb taps on uiautomator dumps), with /sdcard/AASrc, /sdcard/AADst and /sdcard/AAMv standing in for two user folders.

  • Copy into a picked folder: one file selected in AASrc, overflow → Copy to…, Internal storage → AADst → Copy here. The file appeared in AADst, stayed in AASrc, the browser came back to AASrc, and the bar still read 1 item — the selection really does survive a copy.
  • Move into a picked folder: the same walk into AAMv. AASrc ended up empty, AAMv held the file, the snackbar said Moved 1 item, the listing said This folder is empty, and the selection bar was gone.
  • The message and its Show button. After a copy into AADst the snackbar read Copied to AADst with Show, and tapping Show opened AADst with the copied file in it. Repeated into a volume root — the case where the path has no name of its own — it read Copied to Internal storage, which is the label rule doing its job rather than 0 leaking into a sentence.
  • The trail and the volume row. Both first read the platform's own name for internal storage — 0 / AADst and a row labelled 0 — which is correct as a path and useless as a label. Fixed by giving the picker the same label rule as the roots screen; the second pass reads Internal storage / AADst. This is the kind of thing only a device shows: the JVM tests use paths, not labels.

The survey-gap session (M17–M20)

Driven on the phone through the real UI, with adb taps on uiautomator dumps.

  • Launcher shortcuts (M20). The two routes that exist only in the manifest and the intent — no JVM test can reach them — were launched the way the launcher does it: am start -a android.intent.action.VIEW -n de.softwareapp_hb.filemanager/.MainActivity --es de.softwareapp_hb.filemanager.SHORTCUT images opened the Images library directly, and … trash opened the Trash. recent was not launched separately: same intent, one enum value, one code path.
  • The trash window (M19). The trash screen read Deleted files are kept for 24 hours above Nothing in the trash — the sentence and the empty state together, so the retention is stated rather than inferred.
  • Not driven on the device: the change mark (M17) and the compress dialog (M18). Both are covered by tests — the dot by three Compose tests (present, absent, and its content description) and the dialog's two answers by view-model tests — but the visual check (a dot beside a freshly made archive, the switch in the dialog) is still owed. The terminal lost command output halfway through this session, so the driven pass stopped after the two checks above rather than being reported as complete.

The index-restart fix

Reported from use: "the index seems to be recreated at each input of the search bar, and if I switch to another library the reindexing starts from begin again". Reproduced and measured on the phone, with the app's data cleared first so the index was empty and a walk had to happen, and the 20 000-file fixture in place:

typed so far progress row
f Indexing 5 600 files…
fi Indexing 6 300 files…
fil Indexing 7 100 files…
file Indexing 7 100 files…, then Index updated 2026-10-18 10:52

The counter climbing across keystrokes is the fix; before it, each keystroke cancelled the walk (the pass ran inside the search's own job, which is replaced on every letter) and the next one began again at the first folder, so the row read a fresh, small number every time. The walk finished on the last sample and stamped the volume, which is what makes the next search cheap instead of another walk.

The Copy to… / Cut to… session

The selection bar's clipboard was replaced by Copy to… and Cut to…, both opening the destination picker. Driven on the Xiaomi phone (release build, Android 12), against AADemo:

What Result
The bar Copy to… and Cut to… only — no Copy/Move labels, and no destination items in its overflow.
Copy to… Source → picker (Internal storage → AADemo → Target) → Copy here: 120 × 16 MB copied; Copying files in the shade; completion Copied 121 items to Target with Show.
Cut to… file001.bin → the same picker, titled Cut to… with Cut here: moved to Target; the source count fell from 120 to 119; the selection cleared and Undo was offered.
A name in the way Cutting Source into a Target that already held one raised Items already exist here, with Replace / Skip / Cancel / Keep both; Cancel moved nothing.
Paste The top-bar overflow lists Find duplicates, Storage usage, Trash and Refresh — no Paste here.

The copy was recorded as the foreground-service video (FGS_VIDEO). Not repeated here: the SAF-mode picker (the same dialog, but the granted-tree root was not driven), tablet/landscape layout, and a cut interrupted mid-batch.

Not verified, and why

  • Long-press multi-select via adb: it works, but not every time. input swipe x y x y 900 does open the selection action bar on this MIUI build — it was driven that way to reach Delete, Rename and Compress — but the same gesture has also landed as a plain tap, which opens the row instead of selecting it. Driving it manually on the API 35 AVD then paid for the caution: the compress/extract pass below found the selection bar clipping its own actions. The archive engine itself is covered by end-to-end JVM tests, including a zip-slip archive.
  • Folder open, thumbnail paint and search timings. Only cold start has been measured — see PERFORMANCE, which also records why the rest needs a benchmark module and a profileable build, and why that module does not exist yet.
  • The SAF folder-picker round trip. Driving the system picker and granting a tree was not attempted; the code path is unit-tested against a stand-in DocumentsProvider.
  • Image viewer and thumbnails. Verified on the AVD in M4/M5; not repeated on the phone.
  • The foreground-service notification during a long copy. Now observed on the API 35 AVD (see above), including its teardown — so what is left unverified is a physical device, where the system may defer or kill a background dataSync service on its own schedule.
  • Archive creation/extraction against real files. Driven end-to-end on the API 35 AVD, one archive of every supported format: ZIP, 7z, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, BZ2 and XZ all extracted through the UI with the right entry names and byte-for-byte contents, and a folder compressed to a .zip that the host's unzip read back. That sweep is what found the .tar.bz2 asymmetry fixed above. Still untested: a hostile archive on a device — the zip-slip and 7z-slip cases are JVM-only — and RAR/CAB/ISO/WIM, which the app refuses by design.