M7: on-device validation - fix usage-screen latency and first-run copy

This commit is contained in:
2026-10-14 17:05:03 +02:00
parent e2cfb5d9ca
commit a79b7eb58e
8 changed files with 158 additions and 27 deletions
+24
View File
@@ -10,8 +10,32 @@ upload needs a **higher `versionCode`** than every previous upload.
## [Unreleased] ## [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 ### 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, - **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 `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 `keystore.properties`, so `bundleRelease` produces a signed, R8-minified, resource-shrunk
+4 -3
View File
@@ -12,9 +12,10 @@ no advertising or tracking SDKs.
## Status ## Status
**M6 — release config & Play readiness: done.** Signed, R8-minified `.aab` builds; the Play **M7 — on-device validation: done.** Run on a real Xiaomi M2101K7AG (Android 12/MIUI). The
release checklist (including the "All files access" declaration) and the privacy policy are permission flows, real-storage browsing, the text editor's read/write path and the
written. See [docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone plan. 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 ## Tech stack
@@ -25,20 +25,34 @@ data class UsageNode(
*/ */
class UsageAnalysis(private val source: StorageSource) { class UsageAnalysis(private val source: StorageSource) {
/** The children of [directory] with their recursive sizes, largest first. */ /**
suspend fun childrenBySize(directory: StoragePath): List<UsageNode> = * The children of [directory] with their recursive sizes, largest first.
source.list(directory) *
.map { entry -> * [onProgress] is called with each child as soon as it has been measured, so a screen
val measured = measure(entry.path) * can show rows appearing instead of staying blank while a large tree is walked —
UsageNode( * measuring the root of a phone's internal storage takes tens of seconds.
path = entry.path, */
name = entry.name, suspend fun childrenBySize(
isDirectory = entry.isDirectory, directory: StoragePath,
sizeBytes = measured.first, onProgress: suspend (UsageNode) -> Unit = {},
fileCount = measured.second, ): List<UsageNode> {
) val measured = ArrayList<UsageNode>()
} for (child in source.list(directory)) {
.sortedWith(compareByDescending<UsageNode> { it.sizeBytes }.thenBy { it.name }) // 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<UsageNode> { it.sizeBytes }.thenBy { it.name })
}
/** The largest files anywhere below [directory], biggest first. */ /** The largest files anywhere below [directory], biggest first. */
suspend fun largestFiles(directory: StoragePath, limit: Int = LARGEST_LIMIT): List<FileEntry> { suspend fun largestFiles(directory: StoragePath, limit: Int = LARGEST_LIMIT): List<FileEntry> {
@@ -48,16 +62,18 @@ class UsageAnalysis(private val source: StorageSource) {
} }
/** Total bytes and file count below [directory]. */ /** Total bytes and file count below [directory]. */
suspend fun total(directory: StoragePath): Pair<Long, Int> = measure(directory) suspend fun total(directory: StoragePath): Pair<Long, Int> {
val entry = source.stat(directory) ?: return 0L to 0
return measure(entry)
}
private suspend fun measure(path: StoragePath): Pair<Long, Int> { private suspend fun measure(entry: FileEntry): Pair<Long, Int> {
val entry = source.stat(path) ?: return 0L to 0
if (!entry.isDirectory) return entry.sizeBytes to 1 if (!entry.isDirectory) return entry.sizeBytes to 1
var size = 0L var size = 0L
var count = 0 var count = 0
for (child in source.list(path)) { for (child in source.list(entry.path)) {
val measured = measure(child.path) val measured = measure(child)
size += measured.first size += measured.first
count += measured.second count += measured.second
} }
@@ -29,6 +29,7 @@ import de.softwareapp_hb.filemanager.domain.OperationKind
import de.softwareapp_hb.filemanager.domain.OperationNotifier import de.softwareapp_hb.filemanager.domain.OperationNotifier
import de.softwareapp_hb.filemanager.domain.OperationResult import de.softwareapp_hb.filemanager.domain.OperationResult
import de.softwareapp_hb.filemanager.domain.UsageAnalysis import de.softwareapp_hb.filemanager.domain.UsageAnalysis
import de.softwareapp_hb.filemanager.domain.UsageNode
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -281,9 +282,24 @@ class BrowserViewModel(
_state.update { it.copy(overlay = Overlay.Usage(path, emptyList(), loading = true)) } _state.update { it.copy(overlay = Overlay.Usage(path, emptyList(), loading = true)) }
viewModelScope.launch { viewModelScope.launch {
val nodes = runCatching { UsageAnalysis(source).childrenBySize(path) } // Rows are published as they are measured: walking a whole phone's internal
.getOrDefault(emptyList()) // storage takes tens of seconds, and a blank screen for that long reads as a
_state.update { it.copy(overlay = Overlay.Usage(path, nodes, loading = false)) } // 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<UsageNode> { 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))
}
} }
} }
+1 -1
View File
@@ -27,7 +27,7 @@
<string name="browser_all_files_body">To browse every folder, grant this app “All files access” in Settings. Without it you can still browse a folder you pick yourself.</string> <string name="browser_all_files_body">To browse every folder, grant this app “All files access” in Settings. Without it you can still browse a folder you pick yourself.</string>
<string name="browser_all_files_action">Grant full access</string> <string name="browser_all_files_action">Grant full access</string>
<string name="browser_tree_title">Choose a folder</string> <string name="browser_tree_title">Choose a folder</string>
<string name="browser_tree_body">Full access was declined, so pick a folder for the app to work in. The grant is remembered across restarts.</string> <string name="browser_tree_body">Without full access you can still work inside one folder you choose here. The grant is remembered across restarts.</string>
<string name="browser_tree_action">Choose folder</string> <string name="browser_tree_action">Choose folder</string>
<string name="browser_tree_granted">Folder added</string> <string name="browser_tree_granted">Folder added</string>
<string name="browser_tree_rejected">The folder could not be remembered</string> <string name="browser_tree_rejected">The folder could not be remembered</string>
@@ -35,6 +35,18 @@ class UsageAnalysisTest {
assertEquals(0L, nodes[3].sizeBytes) assertEquals(0L, nodes[3].sizeBytes)
} }
@Test
fun `rows are reported as they are measured`() = runBlocking {
val seen = mutableListOf<String>()
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 @Test
fun `the largest files are found anywhere below the folder`() = runBlocking { fun `the largest files are found anywhere below the folder`() = runBlocking {
val files = UsageAnalysis(storage()).largestFiles(root, limit = 2) val files = UsageAnalysis(storage()).largestFiles(root, limit = 2)
+62
View File
@@ -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.
+1 -1
View File
@@ -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) | | 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) | | 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`** | | 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 `<plurals>`; `text/` mapping | | M8 | Localisation groundwork | ⏳ | every user-visible string in `res/values/strings.xml` with real `<plurals>`; `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 | | 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 | | M10 | Store assets | ⏳ | icon/feature-graphic generators, per-form-factor screenshots, [STORE_LISTING](STORE_LISTING.md); display name confirmed |