b43718aca8
The three captures no longer show the "Photo access needed" banner, and the results screen is a picture of the generated demo library instead of the developer's own. Both caveats in the file are closed, so replace them with a short note on how the set was actually taken: - 01-empty: the grant went through the app's own "Grant access" button, so checkSelfPermission is genuinely satisfied. This one needed no tap at all: results and the selection live in ScannerViewModel and only the hash index is persisted, so force-stop + relaunch returns to the empty state with the grant still in place. - 02-scanning / 03-results: shot after tapping Scan over the demo library. The headline group is 43.1 MB reclaimable, which out-ranks the biggest group already on the device (39.8 MB), so the first group in frame is generated. - 04-options is new: the Options DropdownMenu with the three sensitivity presets (it is not a bottom sheet) and the trash-vs-permanent toggle. The tick marks and the "37 selected" bar are the screen's default state, not a staged tap -- a finished scan auto-selects all but best. MIUI still refuses pm grant and synthetic taps (INJECT_EVENTS), so Grant access and Scan remain the two taps a human has to make; the two screens still missing (the staged state and the system consent dialog) are captured by watching for UI text changes instead, rather than by timing a scripted tap.
217 lines
12 KiB
Markdown
217 lines
12 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
|
||
```
|
||
|
||
Two more screens are worth adding to the set. Each needs a tap, and MIUI only accepts taps from a
|
||
human, so they are captured by hand — 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, and it catches the menu, the staging and a system dialog in one pass:
|
||
|
||
- the staged state (*“All duplicates are ready to go”*) after *Remove all selected*. Nothing is
|
||
deleted at that point and no dialog appears yet — the rows simply leave the list.
|
||
- Android's **confirmation dialog** for the whole batch, which *Apply* launches. Dismissing it is
|
||
non-destructive: the staging is kept, and *Apply* can be tapped again.
|
||
|
||
## 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).
|
||
- **Tags:** duplicate photos, photo cleanup, storage.
|
||
- **Contact details:** the same e-mail as in [PRIVACY.md](../PRIVACY.md) — replace the
|
||
`support@example.com` placeholder there first.
|