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/.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user