1f94d0a38a
docs/PERFORMANCE.md has been honest that three of its four numbers do not exist. They exist now as measurements, if not yet as results: benchmark/ is a macrobenchmark module with four tests - cold start with StartupTimingMetric, then opening, scrolling and searching a folder of a thousand files with FrameTimingMetric - sharing a fixture at /sdcard/AABenchmark that is created once per run and deleted in @AfterClass. Frames rather than a named trace section on purpose: what a user calls slow is jank on screen, and the alternative puts benchmarking calls into production code for the sake of a number. CompilationMode.None() is the honest cold-install baseline; Partial() plus a baseline profile is the follow-up and needs the same module. The storage grant is applied with appops in setup, which is what lets the run be unattended - a permission dialog is the one thing a test cannot answer. Three build facts had to be discovered rather than designed, and each is now written where it will be read. The app under test cannot keep its own package id: it needs a debug signature to install on a test device, so it cannot replace a release install, and an un-suffixed build is exactly that. It also cannot use the obvious suffix: `.benchmark` is already the benchmark module's own APK id, and the test APK then fails to install with INSTALL_FAILED_VERSION_DOWNGRADE - which is what the first attempt did. Hence `.perf`. And `testBuildType = "benchmark"` cannot be set unconditionally, because it makes benchmark the variant that *unit* tests run against and :app:testDebugUnitTest - the task the entire gate is built on - stops existing; it is behind -PbenchmarkBuild instead. All three were measured, not guessed, and the third would have broken CI silently. Two smaller things came with it. tools/verify.sh --release now also compiles the app's benchmark variant and the test APK, because build wiring nobody runs is wiring that rots; no device is involved. And the code map grew an EXTRA_ROOTS section so it indexes benchmark/ too - a map that silently omits a module lies by omission, which is worse than the one line it costs. The numbers themselves are still missing, and for a device reason rather than a code one: installing the new package id on this MIUI phone fails with INSTALL_FAILED_USER_RESTRICTED (the "Install via USB" gate, which wants a human and answered too late). Both APKs are pushed to /sdcard/Download/ for a manual install, and the run is one command away: `adb shell am instrument -w -e class de.softwareapp_hb.filemanager.benchmark.BrowserBenchmark de.softwareapp_hb.filemanager.benchmark/androidx.test.runner.AndroidJUnitRunner`. Gate green with --release, including the new compile step; map current; 386 tests unchanged.
319 lines
13 KiB
Kotlin
319 lines
13 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 signed with the
|
|
* debug key so it can be installed on a test device. The manifest in src/benchmark adds the
|
|
* one thing a benchmark cannot work without — `<profileable android:shell="true"/>`.
|
|
*
|
|
* The suffix is not cosmetic, and it is `.perf` rather than `.benchmark` on purpose: the
|
|
* benchmark module's own APK is `de.softwareapp_hb.filemanager.benchmark`, so an app under
|
|
* test with that id collides with the test APK and the install fails with
|
|
* INSTALL_FAILED_VERSION_DOWNGRADE. Without a suffix at all, this build would have the app's
|
|
* own package id and a debug signature — which cannot be installed beside a release build,
|
|
* and would replace a release install if the keys happened to match. A benchmark must never be
|
|
* able to take over the shipped app.
|
|
*/
|
|
create("benchmark") {
|
|
initWith(getByName("release"))
|
|
applicationIdSuffix = ".perf"
|
|
signingConfig = 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
|
|
*/
|
|
if (providers.gradleProperty("benchmarkBuild").isPresent) {
|
|
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",
|
|
)
|
|
}
|