cb2db9ab63
A separate package id for the app under test is the tidier design and cannot be used on this device: a new package id needs a human to approve the install every time, and installing by tapping the APK left the package listed by pm list packages yet unknown to pm enable and invisible to PackageManager. The benchmark build type therefore keeps the app's own id and takes the release key, so it updates an installed release build and keeps its data. The trade-off is stated where it happens: this build replaces a release install, and reinstalling the release APK puts it back. Two bugs surfaced on the way and both are now commented at the line they belong to. -PbenchmarkBuild was read with providers.gradleProperty, which reported it absent, so the runner installed the debug APK and failed on it; hasProperty is what works. And the reason am instrument cannot be used for this module is now recorded: MacrobenchmarkRule uninstalls the target at the start of a run and reinstalls it from the APK paths the Gradle runner passes in, so without those the app is uninstalled and left that way - which is what produced NameNotFoundException for an app that was installed a second earlier, twice, with Benchmark: Uninstalling in logcat to prove it. coldStart keeps its measured number (532 ms median). The three interaction benchmarks remain unmeasured for a reason that is now written down: each run needs one tap on the phone for the reinstall MIUI gates. Gate green, map current.
332 lines
14 KiB
Kotlin
332 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
|
|
}
|
|
|
|
/*
|
|
* Which build type the instrumentation tests run against — only when asked for.
|
|
*
|
|
* Setting this unconditionally also makes `benchmark` the variant that *unit* tests run against,
|
|
* and `:app:testDebugUnitTest` — the task the gate and CI are built on — stops existing. That is
|
|
* measured rather than guessed: with it set, the only unit-test task left was
|
|
* `testBenchmarkUnitTest`.
|
|
*
|
|
* ./gradlew -PbenchmarkBuild :benchmark:connectedDebugAndroidTest
|
|
*
|
|
* `hasProperty` rather than `providers.gradleProperty`: the latter is a Provider, and inside this
|
|
* block it read as absent even when the flag was passed — which showed up as the benchmark
|
|
* installing the *debug* APK and failing on it.
|
|
*/
|
|
if (project.hasProperty("benchmarkBuild")) {
|
|
testBuildType = "benchmark"
|
|
}
|
|
|
|
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",
|
|
)
|
|
}
|