Play gives phones and each tablet size a screenshot slot of their own (Phone, 7-inch tablet, 10-inch tablet) and asks for at least four screenshots per large-screen form factor, so the normaliser takes FORM_FACTOR=phone|tablet-7in|tablet-10in and keeps one raw and one output directory per factor: store-assets/raw-screenshots/<form-factor>/ adb captures (git-ignored) store-assets/screenshots/<form-factor>/ what gets uploaded The tablet form factors crop 0 px of system chrome on purpose, and that is checked rather than guessed: every tablet profile in the SDK's own device definitions is already inside Play's 2:1 cap (7in WSVGA 1024x600, 10.1in WXGA 1280x800, Nexus 7 2013 1200x1920, and Nexus 10 / Medium Tablet / Pixel Tablet at 2560x1600), so the 93/130 px phone crop exists only because a 1080x2400 phone frame is 2.22:1. Cropping a tablet is therefore cosmetic, and is now opt-in through STATUS_BAR_PX / NAV_BAR_PX. The 2:1 clamp still runs for every form factor, and each file is now checked against Play's limits as it is written (shorter side >= 320 px, longer side <= 3840 px) - the phone captures only ever got that check by hand. A missing input directory or an unknown form factor now fails with an actionable message instead of a bash error. The four existing phone captures move to raw-screenshots/phone/ and normalise to the same 1080x2160 RGB files as before, byte for byte. The 7-inch and 10-inch captures were taken with this script on AOSP AVDs; how, and the numbers they show, are recorded in docs/STORE_LISTING.md. tools/make-store-assets.sh now prints the two tablet invocations as well, so the obvious next step after generating the icon and feature graphic is not the phone-only one.
Foto Dup Finder
A fast, ad-free, fully offline Android app that finds and removes duplicate and near-duplicate photos. Built as a direct answer to the slow, ad-laden cleanup tools that ship with many phones.
Nothing ever leaves the device: the app declares no network permission, contains no analytics and needs no account.
Features
- Two kinds of duplicates
- Exact — byte-identical copies, found with a cheap size/head/tail signature followed by a full SHA-256.
- Near — resized, recompressed or lightly edited versions, found with a 64-bit
perceptual difference hash (
dHash) plus a Hamming-distance threshold.
- Tiered, parallel pipeline: only plausible candidates get a full hash, and the work is spread across all CPU cores.
- Persistent index so a re-scan only touches new or changed files.
- Safe deletion through the system consent dialog
(
MediaStore.createDeleteRequest) or the system trash (createTrashRequest). The app never requestsMANAGE_EXTERNAL_STORAGE. - Two-step cleanup: removing a row only stages it (the row disappears instantly, nothing is deleted); one Apply then performs the removal, so Android asks for a single confirmation for the whole batch instead of one per group.
- Sensible defaults: “select all but best” keeps the largest (then newest) copy of each group.
- Android 14+ partial photo access handled explicitly, with a clear prompt to upgrade to full access.
Tech stack
| Area | Choice |
|---|---|
| Language | Kotlin 2.4.10 (using AGP 9 built-in Kotlin) |
| UI | Jetpack Compose, BOM 2026.06.01, Material 3 |
| Build | Gradle 9.3.1, Android Gradle Plugin 9.1.0 |
| Image loading | Coil 3.5.0 |
| Storage | MediaStore read + a small SQLite hash cache |
| SDK levels | compileSdk/targetSdk 36, minSdk 26 |
targetSdk is 36 because Google Play requires new apps and updates to target
Android 16 (API 36) from 31 August 2026.
Version note: the Compose BOM is pinned to 2026.06.01 (Compose 1.11.4) and Coil to 3.5.0, because the next releases require
compileSdk 37, which AGP 9.1.0 does not support yet. Bump both together once a newer AGP lands.
Project layout
app/src/main/java/de/softwareapp_hb/fotodup/finder/
├── core/ Pure-Kotlin duplicate engine (no Android imports!)
│ ├── Hashing.kt SHA-256 + cheap size/head/tail signature
│ ├── PerceptualHash.kt 64-bit dHash + Hamming distance
│ ├── DuplicateGrouper.kt exact + perceptual grouping (union-find)
│ ├── DuplicatePolicy.kt Exact / Balanced / Aggressive thresholds
│ └── …
├── data/ MediaStore access, hashing, permissions, SQLite index, deletion
├── domain/ ScanEngine (tiered pipeline) + progress/result models
└── ui/ Compose theme, scanner screen, ViewModel, components
app/src/test/java/… JVM unit tests for the core engine
core/ deliberately has no Android dependencies, so the trickiest logic
(hashing, perceptual hashing, grouping) is covered by fast JVM tests.
Building
Prerequisites:
- JDK 17+ (JDK 25 verified)
- Android SDK with
platforms;android-36andbuild-tools;36.0.0
# Tell Gradle where the SDK lives (this file is git-ignored)
echo "sdk.dir=$HOME/Android/Sdk" > local.properties
./gradlew :app:assembleDebug # debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests for the engine
./gradlew :app:lintDebug # Android Lint
./gradlew :app:assembleRelease # R8-minified release APK
./gradlew :app:bundleRelease # Play-ready .aab
Installing on a device
adb install -r app/build/outputs/apk/debug/app-debug.apk
How detection works
MediaStore query
│
▼
1. quick signature = SHA-256(size + first & last 32 KB)
│ bucket by (size, signature)
▼
2. full SHA-256 only for buckets with 2+ members → EXACT duplicates
│
▼
3. perceptual fingerprint, both from ONE decode → NEAR duplicates
· pHash: 32x32 grayscale → DCT → 8x8 low-frequency block → 64 bits
· RGB dHash: one 64-bit difference hash per colour channel
│ candidates via 8-bit band index; a pair must pass BOTH tests
│ the pHash cut is fitted per scan (see below), then union-find clusters
4. SQLite hash cache → the next scan only re-reads changed files
Why two descriptors? pHash sees luminance structure, the RGB hashes see colour structure. A single Hamming threshold cannot hold out as a library grows, because unrelated pairs accumulate with the square of the library size. Measured on the benchmark harness, the widest pHash threshold with zero false pairs fell from 13 (120 images) to 5 (2 880 images) — so the app fits the cut to the library instead of trusting a constant, and requires the colour structure to agree as well.
Sensitivity presets (both values are ceilings; the pHash one is adapted per scan):
| Policy | pHash ceiling | RGB ceiling | Notes |
|---|---|---|---|
| Exact copies only | – | – | byte-identical files, no decoding at all |
| Balanced (default) | 12 bits | 60 / 192 | the recommended setting |
| Aggressive | 18 bits | 84 / 192 | catches heavier re-edits |
Banding gives exact candidate recall for pHash distances up to 7 (see the comment in
DuplicateGrouper).
Permissions
| Permission | Why it is needed |
|---|---|
READ_MEDIA_IMAGES (API 33+) |
read the photo library |
READ_EXTERNAL_STORAGE (API ≤ 32) |
same, on older devices |
READ_MEDIA_VISUAL_USER_SELECTED (API 34+) |
Android 14 "Selected photos" mode |
WRITE_EXTERNAL_STORAGE (API ≤ 29 only) |
legacy delete path on Android 10 |
POST_NOTIFICATIONS (API 33+) |
shows the scan-progress notification (optional — the scan runs without it) |
FOREGROUND_SERVICE + FOREGROUND_SERVICE_DATA_SYNC |
keeps a long scan alive while the app is backgrounded |
Deletion on Android 11+ always goes through the system consent dialog. The app
never requests MANAGE_EXTERNAL_STORAGE.
Scanning in the background
A scan is owned by ScanController (process-scoped, created by the Application), not
by the screen's ViewModel. While one is running, ScanService holds a dataSync
foreground service with a progress notification that has a Cancel action. So:
- leaving the app does not kill the scan,
- the result survives screen rotation and re-entry,
- the user always has a visible way to stop it.
When POST_NOTIFICATIONS is denied the scan still works; the notification is simply
not shown.
Testing
app/src/test holds JVM unit tests for core/: SHA-256 test vectors, dHash bit
patterns, Hamming distance, exact/perceptual grouping, aspect-ratio filtering and
the reclaimable-bytes maths.
./gradlew :app:testDebugUnitTest
There is no instrumentation test and no emulator in this environment, so UI
behaviour is verified on a real device via adb install or Play internal testing.
Releasing to Google Play
See docs/PLAY_RELEASE_CHECKLIST.md (signing, store listing, Data Safety, release notes, rollout), docs/STORE_LISTING.md (ready-to-paste listing copy plus the icon / feature graphic generators) and PRIVACY.md.
The upload-ready graphics and screenshots live in store-assets/.
tools/make-store-assets.sh and tools/make-store-screenshots.sh regenerate them from the
app's own icon vectors and from adb captures — always inside the repo, never in $HOME.
python3 tools/make-demo-photos.py draws the synthetic photo library the results screenshot is
captured over, so the listing never shows a real one.
User-visible changes are tracked in CHANGELOG.md; the Play "What's new" text you type per release is separate and lives only in the Play Console.
Localisation
All user-visible text lives in app/src/main/res/values/strings.xml, with proper <plurals>
where a count can be one, and both the screen and the progress notification resolve their
labels through text/TextRes.kt. English is currently the only locale.
Adding a language is then a file, not a refactor:
app/src/main/res/values-de/strings.xml # same keys, translated values
One caveat worth knowing: Google Play's automatic translation translates your store
listing, not the app UI. Translated UI text has to be supplied in values-<locale>/, by a
translator or by running the strings file through a translation service and reviewing it.
Byte units ("KB", "MB") and the duration formatter are intentionally left unlocalised.
Known limitations
- Only images are scanned — videos are not supported yet.
- Near-duplicate detection ignores EXIF rotation, so an image saved physically rotated by 90° will not match its original.
- The Aggressive policy may miss some pairs because of the banded candidate search.
- The very first scan of a huge library (50k+ photos) takes a while; later scans reuse the hash index and are much faster.
Roadmap
The project is tracked against a milestone plan (M0–M10). What is done, what remains, the current release blockers and the candidate features all live in docs/ROADMAP.md.
Licence
Proprietary — all rights reserved. Change this if you intend to open-source it.