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 — ``.
*
* 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().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 = 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",
)
}