4846454645
The profile is real: 13193 lines in app/src/main/baseline-prof.txt, 584 of them the app's own classes, bundled into the release APK as assets/dexopt/baseline.prof. It is produced by a new :baselineprofile test module that walks the first journey - roots screen, one volume opened - under the androidx.baselineprofile plugin, and :app now depends on androidx.profileinstaller so the bundled profile is applied at install time. Two device facts decided how it was made. Collection requires API 33+ (BaselineProfileRule refuses Android 12 outright), so it ran on the API 35 AVD that was already in ~/.android/avd; that emulator is software-rendered and needs androidx.benchmark.suppressErrors=EMULATOR for any macrobenchmark. And the release build needed profileable android:shell=true, which is why app/src/release/AndroidManifest.xml exists and says why: the same APK is both the thing measured and the thing shipped, so the profile's class names are the ones in the shipped dex. The effect is not measured, and PERFORMANCE.md says so: 7404 ms median without the profile against 9005 ms with it, run to run, because a TCG emulator varies by more than a profile can buy. What remains is one run on a physical API 33+ phone, with the two compile commands documented. The emulator did settle the other open question - FrameTimingMetric there reports frameDurationCpuMs and frameOverrunMs, the metrics this phone cannot produce at all. :baselineprofile is compiled by tools/verify.sh --release like :benchmark, so it cannot rot between device runs.
328 lines
14 KiB
Kotlin
328 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)
|
|
// Writes the baseline profile that `:baselineprofile` generates into this module's sources.
|
|
alias(libs.plugins.androidx.baselineprofile)
|
|
}
|
|
|
|
/*
|
|
* 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 {
|
|
// Applies the bundled baseline profile on the device; see docs/PERFORMANCE.md.
|
|
implementation(libs.androidx.profileinstaller)
|
|
baselineProfile(project(":baselineprofile"))
|
|
|
|
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",
|
|
)
|
|
}
|