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:
Hadrian Burkhardt
2026-10-13 23:13:39 +02:00
commit 4706e2669c
100 changed files with 8998 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only test hook — merged into the *debug* manifest alone, never the release one.
It exists because the test device (MIUI) denies `adb shell input` (INJECT_EVENTS), so a
scan cannot be started by tapping the button and `pm grant` is refused as well. The
receiver lets a scan be driven over adb instead:
adb shell am broadcast -a de.softwareapp_hb.fotodup.finder.debug.START_SCAN \
-n de.softwareapp_hb.fotodup.finder.debug/de.softwareapp_hb.fotodup.finder.debug.DebugScanTrigger
Verified absent from the release manifest by the permission/receiver audit in
docs/PLAY_RELEASE_CHECKLIST.md § 5.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
Debug-only. This device (MIUI) refuses every shell permission grant — `pm grant` and
`install -g` both fail with "Neither user 2000 nor current process has
GRANT_RUNTIME_PERMISSIONS" — and `adb shell input` is denied, so the normal
"Grant access" tap is impossible.
"All files access" *can* be granted from adb (`appops set <pkg>
MANAGE_EXTERNAL_STORAGE allow`), and MediaProvider honours it, which is enough to
exercise the real scan over the device's photo library:
adb shell appops set de.softwareapp_hb.fotodup.finder.debug MANAGE_EXTERNAL_STORAGE allow
The release manifest must never contain this permission — re-check it with the
audit command in docs/PLAY_RELEASE_CHECKLIST.md § 5.
-->
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application>
<receiver
android:name=".debug.DebugScanTrigger"
android:exported="true"
tools:ignore="ExportedReceiver" />
</application>
</manifest>
@@ -0,0 +1,57 @@
package de.softwareapp_hb.fotodup.finder.debug
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import de.softwareapp_hb.fotodup.finder.FotoDupApplication
import de.softwareapp_hb.fotodup.finder.core.DuplicatePolicy
import de.softwareapp_hb.fotodup.finder.data.AppPermissions
/**
* Debug-only hook that lets a device scan be driven over `adb` — see `src/debug/AndroidManifest.xml`.
*
* Not part of the app: it lives in the debug source set, so it is absent from the release build.
* It reports everything it does through [TAG] so `adb logcat -s FotoDupDebug` shows the outcome.
*/
class DebugScanTrigger : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val app = context.applicationContext as? FotoDupApplication ?: return
val controller = app.scanController
when (intent.action) {
ACTION_START_SCAN -> {
val policy = when (intent.getStringExtra(EXTRA_POLICY)?.lowercase()) {
"exact" -> DuplicatePolicy.EXACT_ONLY
"aggressive" -> DuplicatePolicy.AGGRESSIVE
else -> DuplicatePolicy.BALANCED
}
Log.i(TAG, "START_SCAN policy=$policy access=${AppPermissions.accessState(context)}")
controller.start(policy)
report(controller)
}
ACTION_REPORT_STATE -> report(controller)
}
}
private fun report(controller: de.softwareapp_hb.fotodup.finder.domain.ScanController) {
val state = controller.state.value
val result = state.result
Log.i(
TAG,
"STATE scanning=${state.scanning} phase=${state.phase} " +
"progress=${state.done}/${state.total} error=${state.error} " +
"photos=${result?.photoCount} groups=${result?.groupCount} " +
"unreadable=${result?.unreadableCount} timings=${result?.timings}",
)
}
companion object {
const val ACTION_START_SCAN = "de.softwareapp_hb.fotodup.finder.debug.START_SCAN"
const val ACTION_REPORT_STATE = "de.softwareapp_hb.fotodup.finder.debug.REPORT_STATE"
const val EXTRA_POLICY = "policy"
const val TAG = "FotoDupDebug"
}
}