Files
file-manager/app/build.gradle.kts
T
SoftwareApp ec76acfdd6 Make the benchmark actually measure the benchmark build, and record what the phone will and will not answer
The run was installing app-debug.apk the whole time, which would have published debug numbers as the release-like ones. AGP 9's com.android.test extension has no testBuildType and no targetVariant - it exposes only targetProjectPath - and the target variant is matched by name against the test module's own build types. So the module now declares a benchmark build type, and inherits the debug signing config with initWith: a declared type starts unsigned, and an unsigned test APK fails with INSTALL_PARSE_FAILED_NO_CERTIFICATES. The command is now :benchmark:connectedBenchmarkAndroidTest, and -PbenchmarkBuild is gone from the workflow; the conditional testBuildType it used to set in :app only ever moved the unit-test variant, which is why it made :app:testDebugUnitTest disappear.

The storage grant also had to move into the test. The rule uninstalls and reinstalls the app before measuring, an uninstall clears app-ops, and appops set from inside the test does not work even under adoptShellPermissionIdentity - the command fails to stderr, which executeShellCommand does not return, and appops get still reads No operations. What does work is tapping what a user taps: Grant full access, then the platform consent, then back out of the all-files page. grantStorageAccess does that in setupBlock, unmeasured, and it is what turned three failing tests into three passing ones.

The search test now waits for the app bar's search action in setup instead of looking it up immediately inside the measured block, where it came back null one frame after a thousand rows rendered.

What the device answers: coldStart passes at 560.6 ms P50 (a third measurement of the 532 ms figure), scroll passes but reports only frameCount and no frame durations at all, open dies on INSTALL_FAILED_USER_RESTRICTED because the rule's reinstall wants a human, and search still cannot see the search action. PERFORMANCE.md now states that per benchmark instead of the earlier guess, names the four switches that make an unattended run possible, and says that three of the four numbers need a device that installs unattended and reports frame timing.
2026-10-17 11:23:36 +02:00

322 lines
14 KiB
Kotlin

import java.util.Properties
import org.gradle.api.artifacts.Configuration
import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.testing.Test
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
/*
* Static analysis for the Kotlin side of the app (Android Lint covers the rest) — see the two tasks
* at the bottom of this file. Nothing to configure here: the CLI is driven by them.
*/
// Optional release signing.
//
// Create a `keystore.properties` file in the project ROOT (it is git-ignored; see
// keystore.properties.example) with:
//
// storeFile=/absolute/path/to/upload-keystore.jks
// storePassword=********
// keyAlias=upload
// keyPassword=********
//
// Without that file the release build still runs R8/minification but produces an
// unsigned APK, which is fine for local checks but cannot be uploaded to Play.
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
val hasReleaseSigning = keystoreProperties.getProperty("storeFile") != null
if (hasReleaseSigning) {
// Fail early and readably instead of letting signReleaseBundle blow up later
// with an opaque NullPointerException.
val missing = listOf("storePassword", "keyAlias")
.filter { keystoreProperties.getProperty(it).isNullOrBlank() }
check(missing.isEmpty()) {
"keystore.properties is incomplete — missing: ${missing.joinToString()}"
}
}
android {
namespace = "de.softwareapp_hb.filemanager"
compileSdk = 36
defaultConfig {
applicationId = "de.softwareapp_hb.filemanager"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
vectorDrawables {
useSupportLibrary = true
}
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
// Falls back to the store password. That matches reality: PKCS12
// keystores (the default on modern JDKs) cannot have a key password
// that differs from the store password, and leaving this null makes
// AGP's signReleaseBundle crash with an NPE.
keyPassword = keystoreProperties.getProperty("keyPassword")
?: keystoreProperties.getProperty("storePassword")
}
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
// Pseudo-locale (en-XA): every string from resources is rendered accented and
// wrapped in brackets, so any text that comes out unchanged is text that never
// went through `strings.xml`. Set the device language to English (XA) to see it.
isPseudoLocalesEnabled = true
}
release {
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
/*
* What macrobenchmark measures: release rules (R8 on, not debuggable) but measurable.
*
* **It keeps the app's own package id on purpose.** A separate id (`.perf`) was the first
* design, and it is the tidier one — a benchmark can never take over a release install — but
* on this MIUI device a *new* package id cannot be installed unattended at all: `adb install`
* is refused with INSTALL_FAILED_USER_RESTRICTED, and installing by tapping the APK left the
* package listed by `pm list packages` yet unknown to `pm enable` and invisible to
* PackageManager, which is exactly what macrobenchmark reports as "Unable to find target
* package". Replacing the installed app is the only path that works without a human in the
* loop for every run.
*
* So it is signed with the release key when that key is configured: same package, same
* signature, so it *updates* the installed app and keeps its data. Without `keystore.properties`
* (CI, a fresh clone) it falls back to the debug key and can then only be installed on a device
* that has no release build of the app.
*
* **This build replaces a release install.** Reinstalling the release APK puts it back.
*/
create("benchmark") {
initWith(getByName("release"))
signingConfig = if (hasReleaseSigning) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
matchingFallbacks += listOf("release")
isDebuggable = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
/*
* No `testBuildType` here on purpose. It reads like "which build the benchmarks run against", but
* in :app it only moves the variant that this module's own *unit* tests use — with it set, the only
* unit-test task left was `testBenchmarkUnitTest` and `:app:testDebugUnitTest`, the task the gate
* and CI are built on, stopped existing. The switch that matters lives in :benchmark, where
* `targetVariant = "benchmark"` makes the run install `app-benchmark.apk` instead of `app-debug.apk`.
*/
buildFeatures {
compose = true
}
testOptions {
unitTests {
// Robolectric needs the merged resources and manifest to stand up the framework.
isIncludeAndroidResources = true
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
// Dependency versions are pinned deliberately — Compose 1.12+/Coil 3.6+ need
// compileSdk 37, which AGP 9.1 does not support yet (see the README version
// note). These advisories are noise rather than signal here.
disable += setOf(
"GradleDependency",
"AndroidGradlePluginVersion",
"NewerVersionAvailable",
)
}
}
kotlin {
compilerOptions {
// With AGP built-in Kotlin this already defaults to
// android.compileOptions.targetCompatibility; set explicitly for clarity.
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
// Robolectric reflects into JDK internals (notably java.io.FileDescriptor), which the module
// system blocks from JDK 17 onwards. Without these it fails with "Failed to interact with raw
// FileDescriptor internals".
tasks.withType<Test>().configureEach {
jvmArgs(
"--add-opens=java.base/java.io=ALL-UNNAMED",
"--add-opens=java.base/java.lang=ALL-UNNAMED",
"--add-opens=java.base/java.lang.reflect=ALL-UNNAMED",
"--add-opens=java.base/java.util=ALL-UNNAMED",
"--add-opens=java.base/java.text=ALL-UNNAMED",
"--add-opens=java.base/java.nio=ALL-UNNAMED",
"--add-opens=java.base/sun.nio.ch=ALL-UNNAMED",
// Robolectric reaches FileDescriptor internals through jdk.internal.access.SharedSecrets, and
// that needs an *export* rather than an *open*: without it the run dies with
// "IllegalAccessException ... module java.base does not export jdk.internal.access".
"--add-exports=java.base/jdk.internal.access=ALL-UNNAMED",
"--add-exports=java.base/jdk.internal.misc=ALL-UNNAMED",
"--add-exports=java.base/jdk.internal.reflect=ALL-UNNAMED",
)
maxHeapSize = "2g"
testLogging { showStandardStreams = true }
}
dependencies {
implementation(libs.androidx.core.ktx)
// SAF backend for the "All files access denied" fallback path.
implementation(libs.androidx.documentfile)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.kotlinx.coroutines.android)
// Image loading for thumbnails and the built-in image viewer.
implementation(libs.coil.compose)
// Video frames, for the same thumbnails: the platform's own retriever, decoding a frame out of
// the clip. It registers itself with Coil through a ServiceLoader entry, so this one line is
// the whole integration — no ImageLoader has to be assembled by hand for it to take effect.
implementation(libs.coil.video)
// ZIP/TAR/GZ archive support. Pure Java — no native code, no NDK.
implementation(libs.commons.compress)
// Commons Compress's JVM-only dependencies for LZMA/LZMA2: `.xz` and 7z. Optional
// upstream, so it has to be declared here or those formats fail at runtime.
implementation(libs.xz)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
// Runs the real Android framework on the JVM, so the data layer is testable without a device.
testImplementation(libs.robolectric)
// Compose rendering tests: the only shape of test here that asserts what a user actually *sees*.
// They run on the JVM through Robolectric too, so they belong to the same gate as everything else
// rather than needing a device. The manifest artifact supplies the activity they are hosted in.
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
// Leak detection, debug builds only. It installs itself; there is no code to add, and nothing of
// it reaches a release build (the dependency is on the debug variant alone).
debugImplementation(libs.leakcanary.android)
// Lets a ViewModel test drive `viewModelScope` on a deterministic dispatcher.
testImplementation(libs.kotlinx.coroutines.test)
}
// ---------------------------------------------------------------- static analysis
/** The detekt CLI, on a configuration of its own so it never becomes an app dependency. */
val detektCli: Configuration by configurations.creating {
isCanBeConsumed = false
}
dependencies {
detektCli(libs.detekt.cli)
}
/**
* Command line shared by [detekt] and [detektBaseline].
*
* The CLI rather than the Gradle plugin, for one concrete reason: the plugin runs the analysis
* inside the build's own JVM, and detekt 1.23 cannot make sense of the version string of the JDK 25
* this project builds with — it fails with `25.0.4.1` as the entire error message. Running the CLI
* through [JavaExec] also lets the analyser run on a 21 launcher, which it does understand.
*/
fun detektArguments(vararg extra: String): List<String> = listOf(
"--input", "src/main/java,src/test/java",
"--config", rootProject.file("config/detekt/detekt.yml").absolutePath,
// The sources use Java 17 language features, which is what compileOptions asks for as well.
"--jvm-target", "17",
// config/detekt/detekt.yml only lists overrides; everything else is detekt's default.
"--build-upon-default-config",
"--parallel",
) + extra
val detektJava = javaToolchains.launcherFor {
languageVersion.set(org.gradle.jvm.toolchain.JavaLanguageVersion.of(21))
}
val detektReports = layout.buildDirectory.dir("reports/detekt")
/**
* Kotlin static analysis, failing on any finding not frozen in `config/detekt/baseline.xml`.
*
* The baseline is the whole adoption strategy: everything detekt finds today is parked there, 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 even in
* a file that already has one.
*/
val detekt by tasks.registering(JavaExec::class) {
group = "verification"
description = "Kotlin static analysis against config/detekt, minus the baseline."
classpath = detektCli
mainClass.set("io.gitlab.arturbosch.detekt.cli.Main")
javaLauncher.set(detektJava)
args = detektArguments(
"--baseline", rootProject.file("config/detekt/baseline.xml").absolutePath,
"--report", "html:" + detektReports.get().file("detekt.html").asFile.absolutePath,
"--report", "sarif:" + detektReports.get().file("detekt.sarif").asFile.absolutePath,
)
}
/** Rewrites the baseline with whatever detekt finds right now. Run it deliberately, not to go green. */
val detektBaseline by tasks.registering(JavaExec::class) {
group = "verification"
description = "Rewrites config/detekt/baseline.xml with the findings that exist now."
classpath = detektCli
mainClass.set("io.gitlab.arturbosch.detekt.cli.Main")
javaLauncher.set(detektJava)
args = detektArguments(
"--baseline", rootProject.file("config/detekt/baseline.xml").absolutePath,
"--create-baseline",
)
}