Files
foto-duplicate-finder/app/build.gradle.kts
T
Hadrian Burkhardt 4706e2669c Initial commit: Foto Dup Finder 1.0.1
An Android duplicate-photo finder. Pure-Kotlin core (SHA-256 + dHash hashing, exact and
perceptual union-find grouping, three sensitivity presets) with MediaStore access, a
persistent SQLite hash index and a tiered ScanEngine behind a Jetpack Compose UI.
No INTERNET permission, no ads, no analytics, no account.

Ships versionCode 2 / versionName 1.0.1, carrying the scan-stall hardening (BoundedCall
breaker) and verified against a 5,363-photo device library. 80 JVM unit tests, lint clean.

Release paperwork is in docs/ (Play checklist, store listing, edge-case testing), the
upload-ready Play graphics and screenshots in store-assets/, and the generators for those
assets in tools/.
2026-10-13 23:13:39 +02:00

195 lines
7.4 KiB
Kotlin

import java.util.Properties
import org.gradle.api.tasks.testing.Test
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
// 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.fotodup.finder"
compileSdk = 36
defaultConfig {
applicationId = "de.softwareapp_hb.fotodup.finder"
minSdk = 26
targetSdk = 36
versionCode = 2
versionName = "1.0.1"
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"
}
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
}
}
// `java.awt` is used by the similarity benchmark (headless), and the benchmark itself is
// opt-in. Gradle's -D only reaches the Gradle JVM, so the flags are forwarded explicitly.
// Run it with:
// ./gradlew :app:testDebugUnitTest --tests '*SimilarityBenchmarkTest*' -DfotoBench=true
tasks.withType<Test>().configureEach {
systemProperty("java.awt.headless", "true")
// 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".
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",
)
systemProperty("fotoBench", providers.systemProperty("fotoBench").orNull ?: "false")
providers.systemProperty("fotoBench.dir").orNull?.let { systemProperty("fotoBench.dir", it) }
providers.systemProperty("fotoBench.count").orNull?.let { systemProperty("fotoBench.count", it) }
providers.systemProperty("fotoBench.scenes").orNull?.let { systemProperty("fotoBench.scenes", it) }
providers.systemProperty("fotoBench.density").orNull?.let { systemProperty("fotoBench.density", it) }
providers.systemProperty("fotoBench.max").orNull?.let { systemProperty("fotoBench.max", it) }
providers.systemProperty("fotoLocal.dir").orNull?.let { systemProperty("fotoLocal.dir", it) }
providers.systemProperty("fotoScale").orNull?.let { systemProperty("fotoScale", it) }
// Real-photo runs hold hundreds of decoded images in memory at once.
maxHeapSize = "2g"
testLogging { showStandardStreams = true }
}
dependencies {
implementation(libs.androidx.core.ktx)
// EXIF orientation, so a photo hashes the same whichever decode path served it.
implementation(libs.androidx.exifinterface)
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.kotlinx.coroutines.android)
implementation(libs.coil.compose)
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)
}