15 KiB
Play Store listing (M10)
Everything the Main store listing page in the Play Console asks for, ready to copy. The graphics are generated from the app's own vectors, so they can be regenerated after any branding change instead of being edited by hand.
See PLAY_RELEASE_CHECKLIST § 4 for where these fit in the release.
Graphics
Generated into store-assets/ inside the repository, never into $HOME. The icon and the
feature graphic are the files that get uploaded to the Play Console, so they are committed and
versioned with the release they belong to. Screenshots are not committed — neither the raw
captures (store-assets/raw-screenshots/) nor the cropped ones (store-assets/screenshots/) —
because a screenshot of this app is a picture of a photo library. Regenerate them with the
commands below and upload them straight from disk.
./tools/make-store-assets.sh # icon + feature graphic -> store-assets/
./tools/make-store-screenshots.sh # crop the adb captures -> store-assets/screenshots/
python3 tools/make-demo-photos.py # synthetic photo library -> demo-photos/ (git-ignored)
| Asset | Play's spec | Result | Source |
|---|---|---|---|
| App icon | 512×512, 32-bit PNG with alpha, ≤ 1 MB | store-assets/icon-512.png — 512×512, RGBA, 6 KB |
tools/store-assets/icon.svg, mirrored from app/src/main/res/drawable/ic_launcher_background.xml + ic_launcher_foreground.xml (paths and group transforms are identical, so the store icon is the launcher icon) |
| Feature graphic | 1024×500, JPEG or 24-bit PNG without alpha | store-assets/feature-graphic-1024x500.png — 1024×500, RGB |
tools/store-assets/feature-graphic.svg |
| Phone screenshots | JPEG or 24-bit PNG without alpha, shorter side ≥ 320 px, longer side ≤ 3840 px, longer side ≤ 2× shorter side | store-assets/screenshots/0{1,2,3,4}-*.png — 1080×2160, RGB |
tools/make-store-screenshots.sh, from raw adb exec-out screencap captures in store-assets/raw-screenshots/ |
Two details worth keeping in mind:
- The icon is full bleed: the adaptive-icon background covers all 108 dp, so every pixel of the PNG ends up opaque and Play's mask can crop to any shape without showing a gap.
- A phone screenshot at the test device's native 1080×2400 is 2.22:1 and therefore
rejected — Play's limit is 2:1. The normaliser crops the status bar (93 px) and the
navigation bar (130 px) and clamps to 1080×2160, which removes system chrome only, never
app content. Re-measure both insets if you shoot on another device and pass
STATUS_BAR_PX/NAV_BAR_PX.
Screenshots
Captured on the test device (1080×2400, Android 12 / MIUI), cropped to 1080×2160:
| File | Screen |
|---|---|
01-empty.png |
Intro / empty state before the first scan — access already granted, so there is no banner and Start scan is enabled |
02-scanning.png |
Scanning progress — “Looking for visually similar photos…” 5353 / 5385, live Cancel |
03-results.png |
Results over the demo library — 17 duplicate groups, 54 of 5385 photos, 83.5 MB reclaimable, the generated 14-copy group first, BEST badge, selection bar |
04-options.png |
Options menu (DropdownMenu off the top bar) — the three sensitivity presets with the ✓ on the active Balanced (recommended), and the trash-vs-permanent toggle |
The tick marks and the 37 selected · 83.5 MB bar in 03-results.png are not a staged tap: a
finished scan auto-selects all but best across every group (ScannerViewModel.autoSelection),
so that is the screen's default state.
The two earlier caveats are closed. The current captures were re-shot through the app's own permission flow and over the generated demo library:
- No “Photo access needed” banner. The runtime grant went through the app's Grant access
button, so
checkSelfPermissionis genuinely satisfied. The test device (Xiaomi M2101K7AG / MIUI) still refusespm grant,install -gand synthetic taps (SecurityException: Injecting to another application requires INJECT_EVENTS), so Grant access and Scan are the two taps a human has to make. Enabling USB debugging (Security settings) in the MIUI developer options is what would make the whole capture run scriptable. - No real photos in frame.
03-results.pngwas shot over the demo library below, and its headline group (43.1 MB reclaimable) outranks the biggest group already on the device (39.8 MB), so the first thing on screen is a generated sunset instead of somebody's library.
Only Scan needed a tap in the end. 01-empty.png did not: scan results and the selection live
in ScannerViewModel (only the hash index is persisted), so force-stopping and relaunching the app
returns to the empty state with the grant still in place:
adb shell am force-stop de.softwareapp_hb.fotodup.finder.debug
adb shell am start -n de.softwareapp_hb.fotodup.finder.debug/de.softwareapp_hb.fotodup.finder.MainActivity
adb exec-out screencap -p > store-assets/raw-screenshots/01-empty.png
Demo library: generated photos, so none of anyone's real ones are published
03-results.png is a screenshot of a photo library, so whatever is on the test device ends up
in the store listing along with it. tools/make-demo-photos.py exists so that library can be
generated instead of borrowed: every scene is drawn procedurally — sunset, mountains, beach,
conifers in haze, dunes, city skyline, night sky, bokeh — so there are no people, no third-party
content and nothing to license. It writes to demo-photos/foto-dup-demo/, which is git-ignored
and regenerated on demand, exactly like fixtures/.
The duplicate structure is deliberate, because a folder of unrelated pictures demonstrates nothing:
- headline group — one scene re-saved at falling quality and size until its reclaimable
size reaches
--target-mb(default 45 MB), - exact pair — two byte-identical files, for the SHA-256 path,
- singles — the other scenes, once each, so the library looks lived-in.
python3 tools/make-demo-photos.py
adb push demo-photos/foto-dup-demo /sdcard/DCIM/foto-dup-demo
adb shell content call --uri content://media --method scan_file \
--arg /sdcard/DCIM/foto-dup-demo
Two non-obvious details, both load-bearing:
- Why the headline group has a target size. Results are ranked by reclaimable bytes, so the
first group on screen is the largest one. A demo group smaller than anything already on the
device would be pushed below the fold and a real photo would be back in frame. The default
--target-mb 45yields a headline group of 43.1 MB reclaimable, which clears the test device's own 39.8 MB with room to spare; raise--target-mbif you capture on a library whose biggest group is larger. scan_fileon the folder, not on the volume. It indexes just that path (API 30+) instead of forcing a MediaStore-wide rescan on a 5,000-photo device. It does recurse into the folder, and re-running it after the folder is deleted drops those rows again — both verified on the test device.
Check the grouping before pushing, rather than reading it off the phone: the desktop harness runs the app's real engine over a scaled copy of the library and reports the two intended groups and nothing else.
magick mogrify -resize 1024x -quality 90 -path build/demo-validate-raw demo-photos/foto-dup-demo/*.jpg
python3 tools/normalise-for-local-grouping.py build/demo-validate-raw build/demo-validate
./gradlew :app:testDebugUnitTest --tests '*LocalGroupingTest*' \
-DfotoLocal.dir="$PWD/build/demo-validate"
# images=22 groups=2 -> PERCEPTUAL 14 copies, EXACT 2 copies; the six singles stay ungrouped
Cleanup, once the captures are done:
adb shell rm -rf /sdcard/DCIM/foto-dup-demo
adb shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo
This was run on the test device as soon as the four captures were taken: MediaStore went from
5,386 to 5,364 image rows and the 22 demo rows are gone. The matching rows in the app's own
hash index are dropped by PhotoIndexStore.pruneTo on the next scan — the index is keyed by
MediaStore id, so there is nothing else to reset.
Two further screens were weighed up and deliberately left out of the set: the staged state (“All duplicates are ready to go”) after Remove all selected, and Android's confirmation dialog for the batch that Apply launches. Staging every selected row also clears the demo group out of the list, which promotes the device's own 39.8 MB group into frame — so those captures could not be published, and since screenshots are not committed there would be nothing to keep them for. (Dismissing the dialog is non-destructive, so nothing is lost by not going that far.)
Captures are taken by hand, because each of these screens needs a tap and MIUI only accepts taps from
a human. A loop that takes a uiautomator dump and screencaps whenever the UI text changes is
enough, so the capture never depends on tap timing; that is how 04-options.png was taken.
Store listing copy
App name (max 30 characters)
Foto Dup Finder
Short description (max 80 characters)
Find and remove duplicate photos. Free, ad-free, works offline.
Alternative, if the no-upload promise should lead:
Find and delete duplicate photos. No ads, no uploads, no account.
Full description (max 4000 characters)
Find and remove duplicate photos, then get the space back. Foto Dup Finder does one
job and does it without ads, without an account and without ever uploading anything.
WHY IT IS DIFFERENT
• No ads — no banners, no interstitials, no upsells.
• No internet permission at all, so the app has no way to upload your photos.
• No account, no sign-in, no tracking, no analytics.
WHAT IT DOES
• Finds exact copies: files that are byte-for-byte identical, even when their names
differ.
• Finds near-duplicates: the same picture saved twice, or two shots of the same
scene. A perceptual hash compares how the images actually look rather than only
their bytes, so a re-compressed or slightly re-cropped copy is still caught.
• Suggests what to keep. The best copy of each group is marked BEST, and
"Select all but best" marks the rest in a single tap.
• Lets you choose how far to go: Exact copies only, Balanced (recommended) or
Aggressive.
• Deletes safely. Every removal goes through Android's own confirmation dialog, and
you choose between moving photos to the trash (recoverable) and deleting them
permanently. Nothing is removed without your confirmation.
• Copes with a messy library. RAW files and unreadable or corrupt images are skipped
and reported instead of stopping the scan.
• Much faster the second time. File hashes are remembered on the device, so
re-scanning an unchanged library takes a fraction of the first run.
HOW IT WORKS
1. Grant photo access — everything runs on your device.
2. Tap Start scan. A library of several thousand photos is done in seconds.
3. Review the groups, keep the copies you want and select the rest.
4. Tap "Remove all selected" and confirm once for the whole batch.
YOUR PHOTOS STAY YOURS
Foto Dup Finder declares no internet permission, ships no advertising or analytics
SDKs, and stores nothing besides a local index of file hashes so the next scan can be
quick. No photo ever leaves your phone, and uninstalling the app removes the index
with it.
GOOD TO KNOW
• Requires Android 8.0 or newer.
• Photos moved to the trash can be restored from your gallery app until the trash is
emptied (Android 11 and newer).
• Works fully offline, in flight mode, always.
Every claim above is checkable against this repository: no INTERNET permission in the
merged manifest, no ad or analytics dependency in gradle/libs.versions.toml, minSdk 26
in app/build.gradle.kts, and the second-run speed-up is the persistent hash index in
data/PhotoIndexStore.kt.
Category and tags
-
Category: Tools (alternative: Photography — Tools matches the one-job utility framing better, and Photography is more crowded). Choose the category first: it shapes the Suggested tags list.
-
Tags: a maximum of five, chosen from Play's own list (Suggested tags, then Search tags / the category filter / Other tags) — there is no free text, and using fewer than five is fine: Google asks for the most relevant tags, and a tag a user cannot connect to the listing costs more than a missing one.
The live list (checked 2026-10-14) contains no cleanup-specific vocabulary at all — no Storage, Photo cleanup, Duplicate photos, Photo management or Photo gallery. Across the whole photo domain Play offers only Photography, Photo editor and Photo frame, so exactly one tag is both available and honest:
Tag Verdict Reason Photography ✅ use The app's subject is the user's photo library, and someone who reads "find duplicate photos" sees immediately why the tag is there. This is the only photo-domain tag that applies. Available while the category is Tools; if the category is Photography it is the category itself and therefore redundant. Privacy & security ⚠️ optional Defensible: the listing leads with "no internet permission at all", "no account, no tracking, no analytics", and that is visible to an unfamiliar user — Google's test. But it pulls in people browsing privacy tools who are not after a photo cleaner. Add it only if that discovery surface is wanted. anything else ❌ Nothing else in the list describes this app; see below. Tags cannot express "duplicate" at all, so search discovery rests on the title and short description keywords, which already say "duplicate photos" — not on this field.
-
Tags deliberately not used: Photo editor, Photo frame, Camera, Video editing, Video players & editors, Network connectivity, Wi-Fi, Virtual private network (VPN). The app cannot edit or take photos and does not scan videos (README › Known limitations), and a connectivity tag would contradict the merged manifest, which declares no
INTERNETpermission. Photo editor is the tempting one — it is what most duplicate-photo apps tag themselves as — but a user arriving from it expects editing tools, which is a one-star review on a listing that has no ratings to absorb one yet. -
Accessibility tags: none (yet). They are a compatibility claim and the UI does not earn one:
ui/components/PhotoThumbnail.ktholds the onlycontentDescriptionin the app, and the scan-progress text has noliveRegion, so progress is not announced automatically. TalkBack support is a follow-up, not a listing decision. -
Contact details:
softwareapp.hb@gmail.com— the same address as in PRIVACY.md, so the policy can be hosted as-is.