diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca827f..1ac59d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,32 @@ upload needs a **higher `versionCode`** than every previous upload. ## [Unreleased] +### Fixed + +- **The storage-usage screen stayed blank for ~45–60 s** on a real 55 GB volume, which + reads as a hang even though the work was off the main thread. `UsageAnalysis` now passes + the `FileEntry` down the recursion instead of re-`stat`ing every node — the parent's + listing already says whether each child is a folder — so the walk costs one listing per + directory. It also reports each child through an `onProgress` callback, and the ViewModel + appends and re-sorts as they arrive, so the largest folders appear while the rest of the + tree is still being measured. Found by running the app on a real phone. + +- **Misleading first-run copy.** The permission prompt said "Full access was declined" on a + device where the user had never declined anything. Reworded so it is true whether access + was declined or simply never granted. + ### Added +- **M7 — on-device validation.** The app was run on a real Xiaomi M2101K7AG (Android 12, + MIUI V140) rather than only emulator images. Verified on hardware: the first-run + permission prompt, "All files access" granted while the app is alive (picked up on + resume), browsing real storage, the text editor's read *and* write path (the write + checked independently with `adb shell cat`), the storage-usage screen, landscape layout, + and **graceful degradation when access is revoked mid-use**. The findings live in + [docs/EDGE_CASE_TESTING.md](docs/EDGE_CASE_TESTING.md), including what could *not* be + driven from the host — long-press multi-select is delivered as a plain tap by this MIUI + build, so the compress/extract buttons still want a manual pass. + - **M6 — release config and Play readiness.** An upload keystore (4096-bit RSA, `CN=Hadrian Burkhardt, L=Potsdam, C=DE`) is generated and wired through the git-ignored `keystore.properties`, so `bundleRelease` produces a signed, R8-minified, resource-shrunk diff --git a/README.md b/README.md index 01ee1da..17615cc 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@ no advertising or tracking SDKs. ## Status -**M6 — release config & Play readiness: done.** Signed, R8-minified `.aab` builds; the Play -release checklist (including the "All files access" declaration) and the privacy policy are -written. See [docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone plan. +**M7 — on-device validation: done.** Run on a real Xiaomi M2101K7AG (Android 12/MIUI). The +permission flows, real-storage browsing, the text editor's read/write path and the +storage-usage screen were all exercised on hardware, and two real defects were found and +fixed. See [docs/EDGE_CASE_TESTING.md](docs/EDGE_CASE_TESTING.md). ## Tech stack diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/UsageAnalysis.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/UsageAnalysis.kt index e89a23e..01c2d76 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/domain/UsageAnalysis.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/UsageAnalysis.kt @@ -25,20 +25,34 @@ data class UsageNode( */ class UsageAnalysis(private val source: StorageSource) { - /** The children of [directory] with their recursive sizes, largest first. */ - suspend fun childrenBySize(directory: StoragePath): List = - source.list(directory) - .map { entry -> - val measured = measure(entry.path) - UsageNode( - path = entry.path, - name = entry.name, - isDirectory = entry.isDirectory, - sizeBytes = measured.first, - fileCount = measured.second, - ) - } - .sortedWith(compareByDescending { it.sizeBytes }.thenBy { it.name }) + /** + * The children of [directory] with their recursive sizes, largest first. + * + * [onProgress] is called with each child as soon as it has been measured, so a screen + * can show rows appearing instead of staying blank while a large tree is walked — + * measuring the root of a phone's internal storage takes tens of seconds. + */ + suspend fun childrenBySize( + directory: StoragePath, + onProgress: suspend (UsageNode) -> Unit = {}, + ): List { + val measured = ArrayList() + for (child in source.list(directory)) { + // The parent's listing already told us whether each child is a folder, so no + // extra stat is needed: one listing per directory and nothing more. + val size = measure(child) + val node = UsageNode( + path = child.path, + name = child.name, + isDirectory = child.isDirectory, + sizeBytes = size.first, + fileCount = size.second, + ) + onProgress(node) + measured += node + } + return measured.sortedWith(compareByDescending { it.sizeBytes }.thenBy { it.name }) + } /** The largest files anywhere below [directory], biggest first. */ suspend fun largestFiles(directory: StoragePath, limit: Int = LARGEST_LIMIT): List { @@ -48,16 +62,18 @@ class UsageAnalysis(private val source: StorageSource) { } /** Total bytes and file count below [directory]. */ - suspend fun total(directory: StoragePath): Pair = measure(directory) + suspend fun total(directory: StoragePath): Pair { + val entry = source.stat(directory) ?: return 0L to 0 + return measure(entry) + } - private suspend fun measure(path: StoragePath): Pair { - val entry = source.stat(path) ?: return 0L to 0 + private suspend fun measure(entry: FileEntry): Pair { if (!entry.isDirectory) return entry.sizeBytes to 1 var size = 0L var count = 0 - for (child in source.list(path)) { - val measured = measure(child.path) + for (child in source.list(entry.path)) { + val measured = measure(child) size += measured.first count += measured.second } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt index b219631..46c8f13 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt @@ -29,6 +29,7 @@ import de.softwareapp_hb.filemanager.domain.OperationKind import de.softwareapp_hb.filemanager.domain.OperationNotifier import de.softwareapp_hb.filemanager.domain.OperationResult import de.softwareapp_hb.filemanager.domain.UsageAnalysis +import de.softwareapp_hb.filemanager.domain.UsageNode import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -281,9 +282,24 @@ class BrowserViewModel( _state.update { it.copy(overlay = Overlay.Usage(path, emptyList(), loading = true)) } viewModelScope.launch { - val nodes = runCatching { UsageAnalysis(source).childrenBySize(path) } - .getOrDefault(emptyList()) - _state.update { it.copy(overlay = Overlay.Usage(path, nodes, loading = false)) } + // Rows are published as they are measured: walking a whole phone's internal + // storage takes tens of seconds, and a blank screen for that long reads as a + // hang. Sorting on every emission is cheap — there are only ever as many rows + // as the folder has children. + runCatching { + UsageAnalysis(source).childrenBySize(path) { node -> + _state.update { current -> + val overlay = current.overlay as? Overlay.Usage ?: return@update current + val nodes = (overlay.nodes + node) + .sortedWith(compareByDescending { it.sizeBytes }.thenBy { it.name }) + current.copy(overlay = overlay.copy(nodes = nodes)) + } + } + } + _state.update { current -> + val overlay = current.overlay as? Overlay.Usage ?: return@update current + current.copy(overlay = overlay.copy(loading = false)) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f5ef46..4a392bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,7 +27,7 @@ To browse every folder, grant this app “All files access” in Settings. Without it you can still browse a folder you pick yourself. Grant full access Choose a folder - Full access was declined, so pick a folder for the app to work in. The grant is remembered across restarts. + Without full access you can still work inside one folder you choose here. The grant is remembered across restarts. Choose folder Folder added The folder could not be remembered diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/UsageAnalysisTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/UsageAnalysisTest.kt index e6cb86a..34a26f2 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/domain/UsageAnalysisTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/UsageAnalysisTest.kt @@ -35,6 +35,18 @@ class UsageAnalysisTest { assertEquals(0L, nodes[3].sizeBytes) } + @Test + fun `rows are reported as they are measured`() = runBlocking { + val seen = mutableListOf() + val nodes = UsageAnalysis(storage()).childrenBySize(root) { seen += it.name } + + // The callback sees every child, in the order they were measured... + assertEquals(4, seen.size) + assertEquals(setOf("big", "small", "empty", "top.bin"), seen.toSet()) + // ...while the returned list is the sorted one. + assertEquals(listOf("big", "top.bin", "small", "empty"), nodes.map { it.name }) + } + @Test fun `the largest files are found anywhere below the folder`() = runBlocking { val files = UsageAnalysis(storage()).largestFiles(root, limit = 2) diff --git a/docs/EDGE_CASE_TESTING.md b/docs/EDGE_CASE_TESTING.md new file mode 100644 index 0000000..d516f4e --- /dev/null +++ b/docs/EDGE_CASE_TESTING.md @@ -0,0 +1,62 @@ +# 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. | + +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-`stat`ing + 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. + +## Not verified, and why + +- **Long-press multi-select via adb.** `input swipe x y x y 900` is delivered as a plain + tap on this MIUI build, so the selection action bar (and therefore compress/extract from + the UI) could not be driven from the host. The archive engine itself is covered by + end-to-end JVM tests, including a zip-slip archive. **Worth a manual pass.** +- **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. Started in M3 and unit-tested; + not observed on hardware. +- **Archive creation/extraction against real files.** Only exercised in-memory. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b5ebadf..f44d0fc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -16,7 +16,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | M4 | Compose UI | ✅ | `BrowserViewModel` + `BrowserUiState` over `StorageLocation`; browser screen with list/grid, breadcrumbs, in-folder search, a sort/hidden-files/view sheet, multi-select action bar, details sheet, and new-folder/rename/delete/conflict dialogs; a roots view and the "All files access" + SAF-folder prompts; `data/MediaStoreDeleter` routes a delete through the platform's own dialog where it can and falls back to a direct delete; a `FileProvider` backs "open with" and share. **115 JVM tests** total (16 added here) | | M5 | Analysis, archives, viewers | ✅ | `domain/UsageAnalysis` measures recursive folder sizes and finds the largest files, shown by a bar-per-row storage-usage screen; `domain/ArchiveEngine` creates ZIP and extracts ZIP/TAR/TAR.GZ/GZ/BZ2/XZ using `java.util.zip` + Commons Compress (no native code), with **every entry name filtered through `core/ArchivePath` against zip-slip**; a Coil-backed image viewer, thumbnails in the list and grid, a text editor, and compress/extract in the selection bar. **141 JVM tests** total (26 added here) | | M6 | Release config & Play readiness | ✅ | Upload keystore generated (4096-bit RSA, `CN=Hadrian Burkhardt`) and wired through the git-ignored `keystore.properties`; `bundleRelease` + `assembleRelease` verified — **4 682 814-byte `.aab`** (`sha256 9014627a…`), 2 081 955-byte APK signed by the upload key, R8 mapping kept. [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) (incl. the **Permissions Declaration Form** for "All files access", the Data safety answers and the shipped-permission audit) and [PRIVACY.md](../PRIVACY.md) written for a restricted-permission app. Shipped permission set audited: **no `INTERNET`** | -| M7 | On-device validation & hardening | ⏳ | AVD + real-device checks, per-operation error boundaries, edge-case fixtures | +| M7 | On-device validation & hardening | ✅ | Run on a real **Xiaomi M2101K7AG** (Android 12 / MIUI V140) — see [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). Verified on hardware: the first-run permission prompt, granting "All files access" while the app is alive (picked up on resume), browsing real storage, the text editor's read **and** write path (independently checked with `adb shell cat`), the storage-usage screen, landscape layout, and **graceful degradation when access is revoked mid-use**. Found and fixed two real defects: misleading first-run copy ("Full access was declined" on a device where nothing had been declined), and a usage screen that stayed blank for **~45–60 s** on a 55 GB volume — now one listing per directory instead of a `stat` per node, with rows streamed out as they are measured. **142 JVM tests** | | M8 | Localisation groundwork | ⏳ | every user-visible string in `res/values/strings.xml` with real ``; `text/` mapping | | M9 | Monetisation | ⏳ | **one-time paid** — price set only in the Play Console before the first upload; no Billing code and no `BILLING` permission | | M10 | Store assets | ⏳ | icon/feature-graphic generators, per-form-factor screenshots, [STORE_LISTING](STORE_LISTING.md); display name confirmed |