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
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Prepare an image folder for a *local* run of the app's grouping logic.
The duplicate engine (`core/`) is pure Kotlin, so it can be exercised on a desktop instead of a
phone. The only Android-specific parts are MediaStore, the image decoder and the hash index — and
the decoder's one subtlety is EXIF orientation.
So this script normalises orientation with Pillow (`ImageOps.exif_transpose`, the reference
behaviour) and writes lossless PNGs plus a manifest. `LocalGroupingTest` then fingerprints those
pixels with the app's own `PerceptualHash`, rebuilds the exact-duplicate path from the recorded
SHA-256, and runs the real `DuplicateGrouper`.
python3 tools/normalise-for-local-grouping.py fixtures/foto-dup-edge build/fotonorm
./gradlew :app:testDebugUnitTest --tests '*LocalGroupingTest*' -DfotoLocal.dir=/tmp/fotonorm
What this reproduces faithfully: the algorithm — hashing, the adaptive threshold, banded candidate
generation, the conjunction, union-find clustering, and the resulting groups.
What it does NOT reproduce: MediaStore's idea of what is indexed, the provider's thumbnail
decode, or the SQLite cache.
"""
import hashlib
import json
import os
import sys
from pathlib import Path
from PIL import Image, ImageOps
SUPPORTED = {".jpg", ".jpeg", ".png", ".bmp", ".heic", ".heif", ".webp"}
def main() -> None:
if len(sys.argv) < 3:
print(__doc__)
raise SystemExit(2)
source = Path(os.path.expanduser(sys.argv[1]))
target = Path(os.path.expanduser(sys.argv[2]))
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
print("note: pillow-heif missing, HEIC files will be treated as unreadable")
target.mkdir(parents=True, exist_ok=True)
for stale in target.glob("*.png"):
stale.unlink()
entries = []
for index, path in enumerate(sorted(source.iterdir())):
if not path.is_file() or path.suffix.lower() not in SUPPORTED:
continue
raw = path.read_bytes()
record = {
"name": path.name,
"sizeBytes": len(raw),
"modifiedSeconds": int(path.stat().st_mtime),
"sha256": hashlib.sha256(raw).hexdigest(),
}
try:
with Image.open(path) as img:
normalised = ImageOps.exif_transpose(img).convert("RGB")
out = target / f"{index:03d}.png"
normalised.save(out, "PNG")
record["normalised"] = out.name
record["width"], record["height"] = normalised.size
except Exception as error: # noqa: BLE001 - mirrors the app's "could not be read"
record["error"] = f"{type(error).__name__}: {error}"
entries.append(record)
(target / "manifest.json").write_text(json.dumps(entries, indent=1))
# Also a line-oriented manifest so the Kotlin side needs no JSON parser. One record per line:
# name, sizeBytes, modifiedSeconds, sha256, normalisedFile, width, height, error
tsv = target / "manifest.tsv"
with tsv.open("w") as handle:
for entry in entries:
handle.write(
"\t".join(
[
entry["name"],
str(entry["sizeBytes"]),
str(entry["modifiedSeconds"]),
entry["sha256"],
entry.get("normalised", ""),
str(entry.get("width", 0)),
str(entry.get("height", 0)),
entry.get("error", "").replace("\t", " ").replace("\n", " "),
]
)
+ "\n"
)
readable = sum(1 for e in entries if "error" not in e)
print(f"prepared {readable} readable of {len(entries)} files in {target}")
for entry in entries:
if "error" in entry:
print(f" unreadable: {entry['name']} ({entry['error'][:60]})")
if __name__ == "__main__":
main()