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().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) // 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", ) }