The MIUI dialog was never the problem to solve; the install was. CompilationMode.None() reports shouldReset = true, which is what makes resetAndCompile uninstall the app and run pm install -t --skip-verification before every test - the one command MIUI intercepts with a FLAG_SECURE dialog that nothing inside a blocking install call can answer. CompilationMode.Ignore() reports shouldReset = false: the library installs nothing and measures what is installed. So the APK is installed once from the host and the run contains no install at all. Measured on the phone: with Ignore() the four tests ran with zero installs - the watcher script logged heartbeats and no dialogs - and three passed. The numbers are the best yet and taken on a run with no install churn: coldStart 519.5 ms median (503.6-544.7), opening a 1000-file folder 14 frames median (11-17), scrolling it with four swipes 94 frames median (48-96). The search benchmark still fails on the app bar's search action not being in the accessibility tree. Also recorded: the storage grant now survives because nothing uninstalls, MIUI refuses to let its SecurityCenter be disabled (Cannot disable miui core packages), and the fix for the whole afternoon is five lines in the benchmark's KDoc plus a documented am instrument command, because Gradle's own install steps are gated too.
15 KiB
Performance
What has been measured, on what, and — explicitly — what has not been measured at all. The rule this file exists to enforce comes from the competitive survey: the market leader's top complaint is thumbnails that load late, its own support reply to a speed complaint was "your SD card is slow", and the lesson recorded there was to measure what was measured and never close a performance complaint by blaming the hardware.
Until this file existed, the app had no performance number of any kind.
Cold start
| build | device | runs (ms) | median |
|---|---|---|---|
debug, 1.0.0-debug |
Xiaomi phone, Android 15 | 1 758 · 1 773 · 1 814 · 1 926 · 2 043 | 1 814 ms |
benchmark (R8, not debuggable, profileable) |
same | — | 532 ms |
The first is ./tools/measure-startup.sh 5 (am start -W, one warm-up start discarded because the first
launch after an install pays for dexopt). The second is BrowserBenchmark#coldStart in the :benchmark
module: StartupTimingMetric, five iterations, CompilationMode.None(), measured as time to initial
display — min 499 ms, median 532 ms, max 805 ms. Measured five times, on four different runs: 615 ms
before the storage grant was in place (the app was showing the permission screen), 532 ms after it, 560 ms
(min 539, max 698), 574 ms (min 554, max 730), and 519.5 ms (min 503.6, max 544.7) on the first run that
used CompilationMode.Ignore(). They agree within a phone's patience; the 532 ms figure above is the middle
one, and the later runs are the ones whose method is worth copying.
Two caveats belong to that number rather than to a footnote. The tool itself warns "Unable to clear
Runtime Image, subsequent launches/iterations may exhibit faster startup than production due to
accelerated class loading", so it is optimistic by an unmeasured amount on this Android version. And
CompilationMode.None() means no compilation profile is applied — what a fresh install does — so a
baseline profile would lower it further and is the obvious next measurement.
The debug build is deliberately the slow case — StrictMode on, LeakCanary attached, no R8 — which is why the same screen takes it three times as long. That is the honest illustration of why a debug number must never be published as the app's performance.
What StrictMode found on the first cold start
The same debug build now logs StrictMode violations, and the first cold start produced five, against a 1 814 ms start:
| duration | where | ours? |
|---|---|---|
| 492 ms | BrowserViewModel.Factory → app.cacheDir |
yes — Context.getCacheDir() checks and creates the private cache directory while the view model is being created, on the main thread |
| 397 ms | BrowserViewModel.rootsOf → StorageProvider.space → VolumeRepository.stat |
yes — free space for every volume is read with StatFs inside refresh(), which the screen calls from a LaunchedEffect |
| 477 ms | FileManagerTheme → typography = FileManagerTypography |
no — the platform loading its own typefaces on first composition |
| 54 ms × 2 | miui.util.font.FontSettings via MainActivity.onCreate |
no — the OEM's font hook doing File.exists while the activity is created |
That is ~1.5 s of main-thread disk I/O inside a 1.8 s start, of which ~0.9 s is ours. Both of ours are mechanical to move and neither is fixed yet: the cache directory is only needed when a preview copies something, and the free-space line on the roots screen can arrive a frame later. They are the first items of the speed work.
Measured on demand: the :benchmark module
benchmark/ holds four macrobenchmark tests that measure the interaction numbers above on a benchmark
build of the app: release rules (R8 on, not debuggable) and profileable, under the app's own package id
and signed with the release key, so installing it updates an installed release build and keeps its
data. A separate package id (.perf) was the first design and is the tidier one, but on this MIUI phone a
new package id cannot be installed without a human: adb install is refused with
INSTALL_FAILED_USER_RESTRICTED, and installing by tapping the APK left the package visible to
pm list packages yet invisible to PackageManager, which is what macrobenchmark reports as "Unable to
find target package". The trade-off is written in :app's build file: this build replaces a release
install, and reinstalling the release APK puts it back.
| benchmark | metric | what it answers |
|---|---|---|
coldStart |
StartupTimingMetric |
tap → first frame |
openAFolderOfAThousandFiles |
FrameTimingMetric |
jank while a 1 000-file folder fills in |
scrollAFolderOfAThousandFiles |
FrameTimingMetric |
jank while scrolling that folder |
searchInsideAFolderOfAThousandFiles |
FrameTimingMetric |
jank while typing into search |
They share a fixture — /sdcard/AABenchmark, a thousand empty files, created by tools/benchmark-fixture.sh
and removed with --remove. CompilationMode.None() is deliberate: no profile is applied, which is the
honest cold-install baseline. CompilationMode.Partial() plus a baseline profile is the follow-up, and
needs the same module.
What the module has measured
The run that first completed all four tests (MIUI's install dialog answered by
tools/benchmark-watch-install.sh, 5 iterations each, CompilationMode.None()):
| benchmark | result | what it is |
|---|---|---|
coldStart |
519.5 ms median (min 503.6, max 544.7) | time to initial display — the fifth measurement of the same thing, alongside 615 / 532 / 560 / 574 ms |
openAFolderOfAThousandFiles |
14 frames median (min 11, max 17) | frames drawn from the folder tap until file-0.txt is on screen |
scrollAFolderOfAThousandFiles |
94 frames median (min 48, max 96) | frames drawn across four swipes through the folder |
searchInsideAFolderOfAThousandFiles |
failed | the app bar's search action is not in the accessibility tree within 20 s of the folder opening, although the same taps by hand find it immediately |
Those frame counts are not a jank metric, and the difference matters. FrameTimingMetric normally
publishes frameDurationCpuMs and frameOverrunMs — how long each frame took and how far past its
deadline it ran. This phone reports neither: the same run's JSON contains frameCount and nothing else,
so "14 frames to open the folder" is how many frames the interaction took, not how long any of them
lasted. It says the interaction is short (14 frames is about a quarter of a second at 60 Hz); it cannot
say whether any of those frames was slow. A device that reports frame durations — an emulator or any
non-MIUI phone — is what turns this into a jank number.
Two notes on provenance. The results JSON from that run was deleted by the next run's cleanup before it
could be copied a second time, so the figures above come from parsing it once, immediately after the run;
re-running on a device without an install gate is the way to confirm them. And the run still ends with a
failing search test, so its number is missing rather than zero.
The install moved out of the test — that is the fix
The problem was never the dialog; it was who installs. CompilationMode.None() reports
shouldReset = true, and that is what makes resetAndCompile uninstall the app and reinstall it with
pm install -t --skip-verification before every single test. On this phone that install is intercepted by
com.miui.securitycenter/.permcenter.install.AdbInstallActivity, which the test cannot answer: the install
call blocks until pm exits, so no test code is running while the dialog is up.
CompilationMode.Ignore() reports shouldReset = **false** — the library installs nothing and measures
what is there. So the APK is installed once, from the host, and the run contains no install at all:
./tools/benchmark-fixture.sh # /sdcard/AABenchmark, 1000 empty files
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk
adb install -r benchmark/build/outputs/apk/benchmark/benchmark-benchmark.apk
adb shell am instrument -w -e class de.softwareapp_hb.filemanager.benchmark.BrowserBenchmark \
de.softwareapp_hb.filemanager.benchmark/androidx.test.runner.AndroidJUnitRunner
Measured: with that command the four tests run with zero installs — tools/benchmark-watch-install.sh
logs heartbeats and nothing else — and three of them pass. Ignore() applies no compilation profile either,
so the numbers still describe code that has never run; a freshly installed APK has no profile to begin with.
Running through Gradle still works, but Gradle's own install steps happen before the tests and are gated
too, which is why am instrument is the documented path.
tools/benchmark-watch-install.sh is kept for the one thing it is good at: the two host-side adb install
commands above. It reads the Install button off the screen and taps only while the installer is focused. It
is not a solution for a run — MIUI keeps mCurrentFocus on the installer for a moment after the window is
gone, so a tap sent on that signal can land on the home screen behind it (observed: it opened Telegram).
Why the dialog could not simply be answered from the test, kept because it explains the shape of all this:
- The dialog is
FLAG_SECURE: ascreencaptaken whilemCurrentFocuswas the installer shows the home screen behind it, which is why screenshots of "the dialog" show nothing. - Its lifetime is not stable. One 0.1 s focus trace caught it as
13:51:27.70 AdbInstallActivity→13:51:28.38 null— too short to read, let alone tap — while another showed the same window focused for 8.5 s. pm installstarted from an app uid does not even reach the gate: MIUI'sXSpaceManagerServiceImplthrowsSecurityException: You either need MANAGE_USERS or CREATE_USERS permission to: query users. The rule's install therefore runs through the shell, the path MIUI intercepts.MIUI's SecurityCenter cannot be disabled to get out of the way:pm disable-useranswersSecurityException: Cannot disable miui core packages.
The four switches that make an unattended run possible at all
- The app under test is the app itself — same package id, release key — because a new package id
cannot be installed on this phone (
INSTALL_FAILED_USER_RESTRICTED, and an APK installed by tapping ended up visible topm list packagesbut invisible toPackageManager). The cost is recorded in:app's build file: this build replaces a release install; reinstalling the release APK puts it back. - The test module declares a
benchmarkbuild type. AGP 9'scom.android.testextension has notestBuildTypeand notargetVariant— onlytargetProjectPath— and the target variant is matched by name against the test module's own build types. Without that type every run installedapp-debug.apk, so the numbers would have been debug numbers. The type also has toinitWith(getByName("debug")): a declared type starts unsigned, and an unsigned test APK fails withINSTALL_PARSE_FAILED_NO_CERTIFICATES. - The storage grant survives because nothing uninstalls any more. An uninstall clears app-ops, so
while the rule was reinstalling the app, a grant applied by the fixture script was gone by the time the
app started — and
appops setfrom inside the test does not work either (even underadoptShellPermissionIdentityit fails to stderr, whichexecuteShellCommanddoes not return, andappops getstill readsNo operations). WithIgnore()the grant lands once and stays.grantStorageAccessinBrowserBenchmarkstill walks the app's own "Grant full access" flow when the app comes up without access, which costs nothing when it is not needed. - The fixture is created through adb, because
UiAutomation.executeShellCommandruns with the test app's uid and cannot write/sdcard.
The workflow
./tools/benchmark-fixture.sh # /sdcard/AABenchmark, 1000 empty files
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk # the build under measure
adb install -r benchmark/build/outputs/apk/benchmark/benchmark-benchmark.apk
adb shell am instrument -w -e class de.softwareapp_hb.filemanager.benchmark.BrowserBenchmark \
de.softwareapp_hb.filemanager.benchmark/androidx.test.runner.AndroidJUnitRunner
One test can be run by naming it: -e class …BrowserBenchmark#coldStart. The numbers are printed in the
instrumentation output itself, and the per-test JSON and Perfetto traces are left in
/sdcard/Android/media/de.softwareapp_hb.filemanager.benchmark/ — they used to be deleted with the test app
when a run ended, which is why the earlier numbers had to be copied mid-run.
Not measured yet, and why
| number | status |
|---|---|
| Folder open with 1 000 files | 14 frames median — measured, but as a frame count, not a jank figure |
| Folder open with 5 000 / 20 000 files | not measured: the fixture script counts to 1 000 |
| Scrolling that folder | 94 frames median over four swipes — again a frame count |
| Typing into search | failed in the automated run: the app bar's search action is not in the accessibility tree within 20 s of the folder opening, although the same taps by hand find it immediately |
| Thumbnail paint while scrolling a photo grid | not measured: no test for it yet, and no frame durations on this device |
What the interaction numbers need is a device that reports frame durations; what the repeated runs need is a device that installs unattended. This phone gives neither, and the two are independent: a non-MIUI phone or an emulator with the system language set to English would answer both, and the fixture is a folder of empty files, so nothing device-specific is being measured.
What is measured instead, meanwhile
The gate measures things that are not latency but do not rot: the test count, lint, detekt against its
baseline, and whether docs/CODE_MAP.md still matches the tree. Those are the numbers that can be
kept true by automation; the three above need a device and a decision about which device.
Two rules
- No number that was not taken from a real build on a real device. Not from a debug build pretending to be release, and not from reading the code.
- No performance change judged by reading the code. The bigger-thumbnail change (28 → 48 dp in a
row, 40 → 96 dp in a grid) is the standing example: it may be exactly right, and nobody measured
either side of it.
docs/COMPETITIVE_ANALYSIS.mdhad already written "a thumbnail that is bigger and slower is strictly worse than one that is smaller and instant" — which is a hypothesis until it is a number.