254 lines
15 KiB
Markdown
254 lines
15 KiB
Markdown
# 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](PLAY_RELEASE_CHECKLIST.md) § 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.
|
||
|
||
```bash
|
||
./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:
|
||
|
||
1. **No *“Photo access needed”* banner.** The runtime grant went through the app's *Grant access*
|
||
button, so `checkSelfPermission` is genuinely satisfied. The test device (Xiaomi M2101K7AG /
|
||
MIUI) still refuses `pm grant`, `install -g` and 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.
|
||
2. **No real photos in frame.** `03-results.png` was 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:
|
||
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
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 45` yields a headline group of **43.1 MB reclaimable**, which clears the test
|
||
device's own 39.8 MB with room to spare; raise `--target-mb` if you capture on a library whose
|
||
biggest group is larger.
|
||
- **`scan_file` on 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.
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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](../README.md#known-limitations)), and a connectivity tag would
|
||
contradict the merged manifest, which declares no `INTERNET` permission. *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.kt` holds the only `contentDescription` in the app, and the
|
||
scan-progress text has no `liveRegion`, 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](../PRIVACY.md), so the policy can be hosted as-is.
|