Files
foto-duplicate-finder/docs/EDGE_CASE_TESTING.md
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

11 KiB
Raw Blame History

Edge-case testing

Fixtures are generated, not committed:

# text/PNG/JPEG fixtures
python3 tools/make-edge-case-fixtures.py

# ...plus HEIC (needs an encoder; ImageMagick's HEIC support is read-only)
python3 -m venv /tmp/heicvenv && /tmp/heicvenv/bin/pip install pillow-heif
/tmp/heicvenv/bin/python tools/make-edge-case-fixtures.py

# push to a phone
adb push fixtures/foto-dup-edge /sdcard/DCIM/foto-dup-edge

Everything lands in fixtures/foto-dup-edge/ inside the repo. The directory is git-ignored — the fixtures are regenerated on demand, so no binary test data is committed.

What each fixture is for

Fixture Expected behaviour
valid.jpg, tiny-1px.jpg, grayscale.jpg, with-alpha.png read normally
zero-bytes.jpg, truncated.jpg, garbage-body.jpg skipped, counted in "could not be read", no crash
renamed-png.jpg (PNG named .jpg) depends on the MIME reported by MediaStore — must not crash
no-extension skipped by MediaStore; must not crash if it appears
decompression-bomb.png (declares 60 000²) skipped, never allocates; exercises DecodeMath
dup-a.jpg / dup-b.jpg one exact-duplicate group
solid-0..5.png Flat colour: every one hashes identically, so they all belong in one group
exif-orientation-1..8.jpg Identical pixels with different orientation tags, so they display differently. Only the upright one groups with the untagged PNG copies; the other seven are correctly distinct. A small group here therefore proves EXIF normalisation is applied — if it were ignored, all eight share identical pixels and would collapse into one group with the PNGs
heic-basic.heic decoded and hashed like any other photo
heic-dup.heic byte-identical to heic-basic → one exact-duplicate group
heic-large.heic (2400×1800) exercises the subsampled fallback decode
heic-solid-0/1.heic ditto the PNG solids, but they should join them: flat images of any colour are indistinguishable to the current descriptors
scene.jpg + scene.heic (one scene, two codecs) informative: is a codec conversion still reported?

Licence

All fixtures are generated locally from synthetic images, so they carry no third-party licence and can be pushed, kept or discarded freely.

The obvious sources for ready-made HEIC samples are not clean:

  • nokiatech/heif_conformance — the canonical HEIC test set, but the repository has no LICENSE file and the README states no terms, so it is all-rights-reserved by default.
  • strukturag/libheif examples/example.heic — the repo is LGPL (library) / MIT (example apps), but the image file itself has no stated licence.
  • Wikimedia Commons — hosts no HEIC files (not an accepted upload format).

That is why the HEIC fixtures are encoded here with pillow-heif instead.

Known gap this fixture set exposed

Flat-colour images are indistinguishable to the current descriptors. A uniform image has no structure, so its pHash is always 1 and its RGB dHash is always 0 — regardless of the colour. A black screenshot, a white scan and a grey solid therefore all hash identically and get reported as mutual duplicates.

This is a genuine false-positive risk on real libraries (dark UI screenshots, blank scans). The fix is to add the colour dimension the descriptors deliberately discard — a small colour histogram is orthogonal to a grayscale structural hash and would separate them (see the benchmark harness, which already has a colour-histogram implementation).

Running the grouping logic on a desktop

The duplicate engine is pure Kotlin, so the algorithm can be exercised without a phone. Only MediaStore, the SQLite index and the notification need Android.

# normalise EXIF orientation with Pillow, then fingerprint and group in Kotlin
python3 tools/normalise-for-local-grouping.py fixtures/foto-dup-edge build/fotonorm
./gradlew :app:testDebugUnitTest --tests '*LocalGroupingTest*' \
    -DfotoLocal.dir=/tmp/fotonorm --rerun-tasks

(--rerun-tasks because Gradle does not always notice a changed -D value on the test task.)

Faithful: hashing, the adaptive threshold, banded candidate generation, the pHash∧RGB conjunction, union-find clustering and the resulting groups. Not faithful: MediaStore's view of what is indexed, the provider's thumbnail decode, and the SQLite hash cache.

The last two matter — see below.

Why the device and a desktop run can disagree on flat images

Local run of the fixture set: all eight solids in one group, as the algorithm demands. On a real device the same eight came back as 3 + 2 + 2.

Reproduced locally by feeding the harness thumbnail-sized JPEGs (96×72, q75) instead of the lossless solids: the result becomes 3 + 2 + 2, matching the device.

Cause: JPEG quantises the DC coefficient, so a one-unit colour difference between neighbouring solids falls below the quantisation step and they become effectively identical. On a device the perceptual hash is taken from the provider's lossy thumbnail, so on a flat image it is hash-of-quantisation-noise — there is no signal left to group by.

Two consequences:

  1. Flat images (blank scans, dark UI screenshots) are grouped arbitrarily. A colour signature is the fix — it is the one dimension the current descriptors discard.
  2. Because the hash comes from the provider's thumbnail, it depends on the thumbnail's re-encoding, not purely on the file. Real near-duplicates survive this (they are close anyway), but the margin is thinner than a self-decoded hash would give.

Running the scan pipeline on the JVM (Robolectric)

ScanEngineEndToEndTest drives the real pipeline — MediaStore query, head/tail signature, SHA-256, SQLite index and grouper — against FakeMediaStoreProvider, a stand-in ContentProvider registered under the MediaStore authority and backed by real files on disk.

./gradlew :app:testDebugUnitTest --tests '*ScanEngineEndToEndTest*'

What it covers: query and projection handling, the head/tail read, the exact-duplicate path over genuine bytes, the index across two runs, unreadable-file reporting, and that the scan runs to completion instead of stalling — the failure that froze on a device.

What it cannot cover: perceptual hashing. Robolectric's ShadowBitmapFactory does not decode real pixel data, so every fingerprint is null and all files are reported as unreadable. The test asserts that reality explicitly rather than pretending otherwise; near-duplicate behaviour has to come from bench/ or from a device.

JDK 17+ needs two different module flags

Robolectric reflects into JDK internals, and on a modern JDK that fails unless the test JVM is opened up. Two distinct flags are needed, and the second is easy to miss:

--add-opens=java.base/java.io=ALL-UNNAMED            # FileDescriptor fields
--add-exports=java.base/jdk.internal.access=ALL-UNNAMED   # SharedSecrets accessor

The second must be an export, not an open: Robolectric calls jdk.internal.access.SharedSecrets#getJavaIOFileDescriptorAccess reflectively, and the failure otherwise surfaces as an opaque IllegalAccessException at Reflection.java:400, wrapped in "Failed to interact with raw FileDescriptor internals; perhaps JRE has changed?".

What cannot be generated locally

  • HEIC with an EXIF orientation tag. pillow-heif accepts exif= on save but the orientation tag reads back as 1 regardless, so a generated fixture would misrepresent what it tests. This case needs a real camera/iPhone HEIC with a non-upright orientation.
  • HEIC from a real encoder in general — worth spot-checking one real iPhone file.
  • Burst sequences / same-scene shots. The one behaviour the benchmark cannot model, and the most likely source of false groups. Needs real photos.
  • Permission revoked mid-scan, and the Android 14 "Selected photos" flow: device only.

Driving a scan on a device without touch input

The test device (Xiaomi M2101K7AG, Android 12 / MIUI, serial 2a2ae895) denies the usual automation, so a scan has to be driven from the app side instead:

Usual approach Result on this device
adb shell input tap/keyevent SecurityException: INJECT_EVENTS — MIUI blocks it, and the toggle that would allow it has no adb-reachable switch
adb shell pm grant <pkg> READ_EXTERNAL_STORAGE SecurityException: Neither user 2000 nor current process has GRANT_RUNTIME_PERMISSIONS
adb install -g same refusal

Two debug-only hooks exist for this, under app/src/debug/. Both are absent from the release build — re-check with the audit command in PLAY_RELEASE_CHECKLIST.md § 5.

Starting a scan

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

Add --es policy exact|balanced|aggressive to override the default (balanced). The receiver logs what it did and the resulting ScanState under the FotoDupDebug tag, so progress and the final timings are readable without the screen:

adb logcat -s FotoDupDebug
# poll the current state at any time:
adb shell am broadcast -a de.softwareapp_hb.fotodup.finder.debug.REPORT_STATE \
  -n de.softwareapp_hb.fotodup.finder.debug/de.softwareapp_hb.fotodup.finder.debug.DebugScanTrigger

Granting photo access

MANAGE_EXTERNAL_STORAGE is the one storage permission that can be granted from adb, which is why the debug manifest declares it:

adb shell appops set de.softwareapp_hb.fotodup.finder.debug MANAGE_EXTERNAL_STORAGE allow
# ...run the scan, then put it back:
adb shell appops set de.softwareapp_hb.fotodup.finder.debug MANAGE_EXTERNAL_STORAGE default

The app's own banner still reads “Photo access needed”, because checkSelfPermission is unaffected by an appops grant. That is expected: it only affects the on-screen prompt, not the scan.

Attaching a debugger for thread stacks

jdb attaches over JDWP to the debug build:

adb forward tcp:8700 jdwp:$(adb shell pidof de.softwareapp_hb.fotodup.finder.debug | tr -d '\r')
printf 'suspend\nwhere all\nquit\n' | jdb -attach localhost:8700

Two things learned the hard way:

  • print needs a selected frame. With no current frame even java.lang.Class reports "Name unknown", so expressions cannot be evaluated until a breakpoint has been reached.
  • Leaving the main thread suspended for more than a few seconds during an activity launch gets the process killed by the launch timeout. Evaluate and cont quickly.