Files
file-manager/app/build.gradle.kts
T
SoftwareApp 0bbee25ab3 Test the interface as a user sees it, and let a debug build tell on itself
Fifteen rendering tests across four files are the first tests in this project that compose a screen,
and they cover the claims that used to be prose only: the app bar's star exists where there is a folder
to star and says Add or Remove according to the state, the selection bar offers Rename to several files
and Details only to one, the batch dialog spells out the names it will produce and refuses a collision
with the file named, the shared name dialog refuses a slash while it is typed, and a favourite that has
gone says "Folder not found" rather than showing a path that is not there. They run on the JVM through
Robolectric - the Compose test artifacts plus the test manifest - so they belong to the same verify.sh
gate as everything else instead of needing a device: 386 tests in total.

Writing them turned up one behaviour worth recording. The batch dialog continues the folder's series by
default, so a collision cannot be shown unless the start number is forced back to 1 - which is precisely
why the feature works in practice, and now also why two of these tests set the field explicitly.

The debug build now enables StrictMode (disk reads and writes, network, leaked closables; logged, not
fatal) and attaches LeakCanary. A file manager is the worst possible app for a blocking disk read on the
main thread, and a debug build is the last place where noticing it is still cheap. Logging rather than a
crash is deliberate: turning a slow screen into a crash on someone's phone is the worse trade. The flag
is read from the manifest, not from BuildConfig, which this build does not generate.

Performance has a number for the first time, and it immediately paid for itself. tools/measure-startup.sh
takes cold start with `am start -W` - median of several runs, one warm-up discarded because the first
launch after an install pays for dexopt - and docs/PERFORMANCE.md records 1814 ms over five runs, in the
debug build, which is the slow case by construction. StrictMode's first run then produced five violations
against that number: 492 ms resolving `app.cacheDir` inside the view-model factory, 397 ms reading free
space with StatFs for every volume inside refresh(), 477 ms of the platform loading its own typefaces,
and two 54 ms OEM font hooks. That is about 1.5 s of main-thread disk I/O in a 1.8 s start, of which
~0.9 s is ours and mechanical to move. Neither of ours is fixed here on purpose: the numbers and the two
fixes are recorded in PERFORMANCE.md, and they are the first items of the speed work.

What is not measured is stated in the same file rather than left to be assumed: folder open at 1 000 /
5 000 / 20 000 files, thumbnail paint while scrolling, and keystroke-to-result all need a `:benchmark`
module and a profileable build under the release package id, and this device waits for a confirmation
dialog on the first install of a new package id. The module is deliberately absent instead of
half-configured: build wiring nobody can run is worse than a recorded gap.

Gate green (map current, detekt, 386 tests, lint), and CHANGELOG, README, AGENTS.md, ROADMAP,
PERFORMANCE.md, EDGE_CASE_TESTING and the regenerated map carry it.
2026-10-17 09:23:11 +02:00

285 lines
11 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",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
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",
)
}