68dcbc5db6
Checked the way a browser does it rather than the way curl does, and curl had given a false green: Gitea sends `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox` with every /raw/ response. That is deliberate - it is what makes serving raw HTML and SVG safe - but it also blocks the media load when a browser navigates straight to a video, so the tab stays blank with no error. The same URL plays fine as a subresource, which is exactly why the file's page in Gitea shows a player: that page embeds <video controls src=".../raw/branch/main/...mp4">. So the declaration's link is the /src/ page URL, and /raw/ is documented as the curl-and-download URL instead. Everything verified about both URLs is written down in step 5 - including the two ways to get a player with no Gitea chrome around it, if that is ever wanted: an unlisted YouTube upload, or dropping the header for this one path on the Gitea host's Caddy proxy, which must stay scoped to store-assets/ precisely because that header is what protects raw HTML and SVG. The file itself was never the problem: 200, video/mp4, Content-Disposition: inline, bytes sha256-identical to the recording, and Range: bytes=0-99 -> 206 with the matching Content-Range.
487 lines
31 KiB
Markdown
487 lines
31 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`, and **committed**:
|
||
the icon, the feature graphic, the twelve normalised screenshots and the foreground-service
|
||
demonstration video all live in git, versioned with the release they belong to. That is deliberate
|
||
— Play wants a *link* to the demonstration video, so an asset has to be reachable without a login,
|
||
and once one is, it is simpler to keep the whole listing beside the code that produces it.
|
||
|
||
Publishing them is safe because of where they are shot: every published frame contains the
|
||
**generated** demo library and nothing else (`tools/make-demo-photos.py`), so the screenshots show
|
||
the app *over* that library. The phone set was re-checked frame by frame before publication; the
|
||
sole trace of the test device's own 5,385-photo library in it is a count in the summary card. What
|
||
stays out of git is the scratch around them — the raw `adb` captures
|
||
(`store-assets/raw-screenshots/`), the demo photos themselves, and the recording script's milestone
|
||
PNGs. Regenerate any of it with the commands below and upload straight from disk.
|
||
|
||
Screenshots live **one directory per form factor**, because Play gives phones, 7-inch tablets and
|
||
10-inch tablets a slot each (`Main store listing` › *Graphics* › *Phone* / *7-inch tablet* /
|
||
*10-inch tablet*):
|
||
|
||
```
|
||
store-assets/raw-screenshots/{phone,tablet-7in,tablet-10in}/ git-ignored `adb` captures
|
||
store-assets/screenshots/{phone,tablet-7in,tablet-10in}/ the files that get uploaded — committed
|
||
```
|
||
|
||
```bash
|
||
./tools/make-store-assets.sh # icon + feature graphic -> store-assets/
|
||
./tools/make-store-screenshots.sh # phone -> screenshots/phone/
|
||
FORM_FACTOR=tablet-7in ./tools/make-store-screenshots.sh # 7-inch -> screenshots/tablet-7in/
|
||
FORM_FACTOR=tablet-10in ./tools/make-store-screenshots.sh # 10-inch -> screenshots/tablet-10in/
|
||
python3 tools/make-demo-photos.py # synthetic photo library -> demo-photos/ (git-ignored)
|
||
```
|
||
|
||
Committing `store-assets/` is also what *publishes* it: Gitea serves every committed file without a
|
||
login, and pushing the commit **is** the deployment step — there is no separate upload, so a
|
||
re-shot screenshot or re-recorded video goes live the moment it is pushed. There are two URL shapes,
|
||
and which one is right depends on what does the reading:
|
||
|
||
```
|
||
https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/raw/branch/main/<path> the bytes — curl, downloads
|
||
https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/src/branch/main/<path> a page — what a browser opens
|
||
```
|
||
|
||
For media that distinction is not cosmetic: Gitea sends `Content-Security-Policy: default-src
|
||
'none'` with `/raw/` responses, so a video opened at a raw URL shows a blank tab, while the same file
|
||
plays on its `src` page. Play's foreground-service declaration therefore takes the **page** URL as
|
||
its video link; the reasoning and what was verified about both URLs are in
|
||
[PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5.
|
||
|
||
| 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/phone/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/phone/` |
|
||
| 7-inch tablet screenshots | same image rules as phone, but Play asks for **at least 4 screenshots** per large-screen form factor and suggests a 16:9 landscape / 9:16 portrait frame | `store-assets/screenshots/tablet-7in/0{1,2,3,4}-*.png` — 1200×1920, RGB | `FORM_FACTOR=tablet-7in tools/make-store-screenshots.sh`, from `Nexus 7 2013` AVD captures in `store-assets/raw-screenshots/tablet-7in/` |
|
||
| 10-inch tablet screenshots | same image rules as phone; 4+ recommended | `store-assets/screenshots/tablet-10in/0{1,2,3,4}-*.png` — 1600×2560, RGB | `FORM_FACTOR=tablet-10in tools/make-store-screenshots.sh`, from `medium_tablet` AVD captures in `store-assets/raw-screenshots/tablet-10in/` |
|
||
|
||
Three 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`.
|
||
- A **tablet** frame has no such problem, which is why the tablet form factors default to
|
||
**0 px of crop**: every tablet profile the SDK ships is between 16:9 and 16:10 — 7" WSVGA is
|
||
1024×600, 10.1" WXGA is 1280×800, and Nexus 10 / Medium Tablet / Pixel Tablet are 2560×1600 —
|
||
so a tablet capture is already inside the 2:1 cap. Cropping the bars there is cosmetic, so it
|
||
is opt-in via `STATUS_BAR_PX` / `NAV_BAR_PX`. The 2:1 clamp still runs, which is what makes the
|
||
tool safe on a device whose frame *is* taller than 2:1.
|
||
|
||
## Screenshots
|
||
|
||
### Phone (1080×2160)
|
||
|
||
Captured on the test device (1080×2400, Android 12 / MIUI), normalised into
|
||
`store-assets/screenshots/phone/` at 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/phone/01-empty.png
|
||
```
|
||
|
||
### 7-inch and 10-inch tablets (1200×1920 and 1600×2560)
|
||
|
||
Play keeps a **separate screenshot slot per form factor**, so the phone set does not fill the tablet
|
||
rows: *Graphics* lists **Phone**, **7-inch tablet** and **10-inch tablet** (plus Chromebook, Android
|
||
TV, Wear OS, Automotive and XR, none of which this app ships to). For tablets and Chromebooks Play
|
||
asks for a **minimum of four** screenshots that "demonstrate your in-app experience" — up to 8 per
|
||
device type — under the same image rules as the phone slot (JPEG or 24-bit PNG, no alpha,
|
||
320–3840 px, longer side ≤ 2× shorter side), with a 16:9 landscape / 9:16 portrait frame *highly
|
||
recommended* rather than required.
|
||
|
||
Captured on two **AOSP Android 12 AVDs**, because there is no real tablet here: `Nexus 7 2013` for
|
||
the 7-inch slot (1200×1920 portrait, `sw600dp`, 320 dpi) and `medium_tablet` for the 10-inch slot
|
||
(1600×2560 portrait, `sw800dp`, 320 dpi). This machine exposes no `/dev/kvm`, so both ran in **pure
|
||
software emulation** (`-accel off -no-window -gpu swiftshader_indirect`): slow — about 3 minutes to
|
||
boot and 1–12 s per screenshot — but rendering, MediaStore and touch injection all behave.
|
||
|
||
| File | 7-inch — `tablet-7in/` | 10-inch — `tablet-10in/` | Screen |
|
||
|---|---|---|---|
|
||
| `01-empty.png` | 1200×1920 | 1600×2560 | Intro / empty state — access already granted, so there is no banner and *Start scan* is enabled |
|
||
| `02-scanning.png` | 1200×1920 | 1600×2560 | Scanning — *“Looking for visually similar photos…”* at **20 / 22**, live *Cancel* |
|
||
| `03-results.png` | 1200×1920 | 1600×2560 | Results over the demo library — **2 duplicate groups, 16 of 22 photos, 43.7 MB reclaimable** (scanned in **39.7 s** on the 7-inch, **53.1 s** on the 10-inch), `BEST` badge, selection bar |
|
||
| `04-options.png` | 1200×1920 | 1600×2560 | Options menu — the three sensitivity presets with the ✓ on *Balanced (recommended)*, and the trash-vs-permanent toggle |
|
||
|
||
The *14 selected · 43.7 MB* bar in `03-results.png` is not a staged tap: a finished scan auto-selects
|
||
*all but best* across every group (`ScannerViewModel.autoSelection`), so it **is** the screen's
|
||
default state — same as on the phone. The figures are the generated demo library's own (22 photos, 16
|
||
of them duplicates), not the phone capture's 5,385-photo ones. Nothing except the demo library was
|
||
ever pushed to either AVD, and it is deleted again after the captures, so no real photo can appear in
|
||
the tablet set.
|
||
|
||
Two things made the tablet captures cheap to take:
|
||
|
||
- **No chrome maths.** No tablet frame is anywhere near 2:1, so both tablet form factors default to
|
||
**0 px of crop** — the four tablet PNGs are exactly the frame `screencap` returned, only flattened
|
||
to 24-bit. Set `STATUS_BAR_PX` / `NAV_BAR_PX` only if you *want* the bars gone; measure them on the
|
||
tablet first, they are not the phone's 93/130.
|
||
- **No new screens.** The app has no large-screen branching at all: `res/` has no `values-sw600dp`
|
||
or `layout-sw600dp`, the UI code uses no `WindowSizeClass` / `BoxWithConstraints`, and the
|
||
activity has no `screenOrientation` lock — a tablet shows the same single-column scanner, wider.
|
||
The shot list is therefore the same four files, and portrait keeps the three sets parallel.
|
||
|
||
One knob needed no attention: a fresh AVD has an empty photo library, so the demo group is the biggest
|
||
one by construction and `--target-mb` needs no tuning — that only matters on a tablet that already
|
||
holds photos (see the demo-library section below).
|
||
|
||
`avdmanager` / `sdkmanager` / `emulator` live in `$ANDROID_SDK_ROOT` (`cmdline-tools/latest/bin` for
|
||
the first two). The device ids are the ones `avdmanager list device` reports: `Nexus 7 2013` is
|
||
1200×1920 xhdpi and `medium_tablet` is 2560×1600 xhdpi (Pixel Tablet class).
|
||
|
||
```bash
|
||
# 1. The two tablet AVDs (AOSP Android 12 images; ~4 GB of SDK downloads).
|
||
avdmanager create avd -n tablet7 -d "Nexus 7 2013" -k "system-images;android-31;default;x86_64"
|
||
avdmanager create avd -n tablet10 -d medium_tablet -k "system-images;android-31;default;x86_64"
|
||
|
||
# 2. Boot it. No KVM here, so: no window, no audio, SwiftShader, no snapshot, software CPU.
|
||
emulator -avd tablet7 -no-window -no-audio -no-boot-anim -no-snapshot \
|
||
-gpu swiftshader_indirect -accel off -memory 2048 -cores 4 &
|
||
|
||
# 3. Emulator housekeeping. Under TCG SystemUI is slow enough to raise an ANR dialog that would sit
|
||
# in frame, and `medium_tablet` boots landscape — portrait is rotation 3 on it, not 0.
|
||
adb shell settings put global hide_error_dialogs 1
|
||
adb shell settings put global window_animation_scale 0
|
||
adb shell settings put global transition_animation_scale 0
|
||
adb shell settings put global animator_duration_scale 0
|
||
adb shell settings put system accelerometer_rotation 0
|
||
adb shell settings put system user_rotation 3 # tablet10 only; the Nexus 7 is portrait-native
|
||
|
||
# 4. The phone set's prerequisites: the app, the permission, the demo library.
|
||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||
adb shell pm grant de.softwareapp_hb.fotodup.finder.debug android.permission.READ_EXTERNAL_STORAGE
|
||
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
|
||
|
||
# 5. Capture. `01` is one screencap. The other three need a tap, so their coordinates come from
|
||
# `uiautomator dump` instead of guesswork, and a screencap burst *started before the tap* is what
|
||
# catches the short scanning screen — 22 photos scan in seconds even under emulation.
|
||
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/tablet-7in/01-empty.png
|
||
# ... burst frames into /tmp, tap "Start scan" (7-inch 600 1155, 10-inch 800 1465), keep the frame
|
||
# showing "Looking for visually similar photos… N / 22" for 02 and the one after it for 03,
|
||
# then tap "Options" (7-inch 1118 112, 10-inch 1518 112) and keep the frame showing the menu.
|
||
|
||
# 6. Normalise — then repeat everything with FORM_FACTOR=tablet-10in on the tablet10 AVD.
|
||
FORM_FACTOR=tablet-7in ./tools/make-store-screenshots.sh
|
||
|
||
# 7. Take the demo library back off the AVD, exactly as on the phone.
|
||
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
|
||
```
|
||
|
||
The 16:10 frame a tablet produces (1200×1920 or 1600×2560) is inside Play's 2:1 cap but is not the
|
||
16:9/9:16 shape the *highly recommended* bullet asks for. Cropping app content to fake 9:16 would be
|
||
worse than missing a soft recommendation, so the captures go up as shot.
|
||
|
||
### 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 committed
|
||
or published. (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.
|
||
|
||
## App pricing
|
||
|
||
**Decision: a one-time paid app — a free download is off the table.** Foto Dup Finder is sold on
|
||
Google Play at a single price: no free tier, no trial, no subscription, no in-app purchase. Nothing
|
||
in this repository changes because of that decision — it lives in the Play Console, and the app
|
||
carries **no Billing code at all** (see [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 4).
|
||
|
||
Why paid rather than free-with-a-Pro-unlock:
|
||
|
||
- The app is a **single-purpose utility with no running costs** — no servers, no sync, no accounts —
|
||
so there is nothing a subscription would fund and nothing to keep charging for.
|
||
- Play Billing would merge `com.android.vending.BILLING` into the manifest and put a purchase screen
|
||
into an app whose whole pitch is "no account, no tracking, nothing leaves the device". A price on
|
||
the listing costs **zero permissions and changes zero Data Safety answers** — a claim no other
|
||
monetisation of this app can make.
|
||
- There is no ad SDK and there never will be: an ad SDK adds the `INTERNET` permission, and the
|
||
merged-manifest and Data Safety claims below would immediately stop being true.
|
||
|
||
⚠️ **This is the last moment the choice is still reversible.** Play's rule, from *Set up your app's
|
||
prices*: *“You can change your app from paid to free. Once your app has been offered for free, the
|
||
app can't be changed to paid. If you want to charge for the app, you need to create a new app with a
|
||
new package name and set a price.”* Nothing has been uploaded yet (`versionCode` 2 is still the first
|
||
release), so a paid app is still possible; after the first upload to any track it is not. Going free
|
||
later stays one click.
|
||
|
||
**Price: one-time, base €2.99 EUR, launched with a 14-day 50% sale at €1.49.** Play derives every
|
||
other market from the base price and applies local pricing patterns, so it is one number to maintain
|
||
rather than a per-country table.
|
||
|
||
**Why the base price is not simply the impulse price.** A €1 base was considered and rejected: Play's
|
||
sales let a paid app be discounted whenever you like, but **nothing lets you un-discount it**.
|
||
Starting at €1 spends the only lever this model has — raising the price afterwards looks like gouging
|
||
to the buyers who told people "it's a euro app" — and there is no free tier or in-app purchase to
|
||
recover the difference later, because **a paid app's first transaction is its only transaction**. At
|
||
€2.99 the launch sale *is* the €1.49 impulse price, and the real price is waiting when it ends.
|
||
|
||
The arithmetic, at Play's current fee for a paid app's initial purchase (10% + 5% billing fee ≈
|
||
**15%** for the first $1M/year of new-install transactions in the EEA):
|
||
|
||
| Price | You net (≈) | Units needed to match €2.99 |
|
||
|---|---|---|
|
||
| €2.99 (base) | €2.54 | 1× |
|
||
| €1.49 (launch sale) | €1.27 | 2× |
|
||
| €1.00 (rejected) | €0.85 | 3× |
|
||
|
||
Three Console details matter:
|
||
|
||
- **Leave "Default price includes tax" off unless you mean it.** Off, VAT is added on top: a German
|
||
buyer sees **€3.56** for the €2.99 base and **€1.77** during the sale. On, the sticker price is the
|
||
gross price and Play takes the tax out of it.
|
||
- **A country you do not set a price for is a country where the app is unavailable.** Play also
|
||
enforces a per-country price range — see *currencies and price ranges allowed by country*.
|
||
- **Strikethrough is not shown everywhere.** In 12 EU member states (Belgium, Croatia, Czech
|
||
Republic, Denmark, Estonia, France, Greece, Hungary, Latvia, Netherlands, Poland, Sweden) and the
|
||
UK, buyers see the discounted price with no reference to the original. Germany is *not* one of
|
||
them, so "was €2.99" is real for a German buyer — which is why the sale is worth running.
|
||
|
||
### The launch sale, and the rules that shape it
|
||
|
||
Play's rules for a paid-app sale are tight enough to design around:
|
||
|
||
- **1–14 days per sale**, and **30 days must pass between the end of one sale and the start of the
|
||
next**. The launch offer is therefore one 14-day window, not "half price for the first month" — a
|
||
deadline-driven event rather than the normal price.
|
||
- **The app must be published before a sale can be created** (*Monetise › Products › App pricing ›
|
||
Sales › Create sale*), so the order is publish first, then schedule the sale; the start date can be
|
||
as early as the next day.
|
||
- The discount is a **fixed price or a percentage of 10–100%**, the discount must be **at least 10%**,
|
||
and the sale price must stay inside the accepted price range. A **100% ($0) sale** exists if the app
|
||
should be briefly free instead.
|
||
- The sale name lives only in the Play Console; buyers never see it.
|
||
|
||
**The real cost of this route: no free trial.** A paid app cannot be installed before it is bought,
|
||
so the listing has to do all the convincing. The two instruments that do *not* require going free are
|
||
the sale above and **promo codes** — a giveaway copy for named testers and reviewers, available at any
|
||
time. Use a promo code, never a real purchase, for your own end-to-end buying test:
|
||
[PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 4.
|
||
|
||
## Store listing copy
|
||
|
||
### App name (max 30 characters)
|
||
|
||
Foto Dup Finder
|
||
|
||
### Short description (max 80 characters)
|
||
|
||
Find and remove duplicate photos. Ad-free, works offline, no uploads.
|
||
|
||
Alternative, if the no-account promise should lead:
|
||
|
||
Find and delete duplicate photos. No ads, no uploads, no account.
|
||
|
||
Neither option may say "free": the app is paid, so the claim would simply be false. Play's metadata
|
||
policy also bans price and promotional wording in the **app title, icon and developer name**
|
||
("free for limited time only" is its own example), which is why the price story below lives in the
|
||
description body and is written as a plain statement of fact — no discounts, no urgency, nothing
|
||
that reads as a promotion.
|
||
|
||
### 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
|
||
• Pay once, then own it. No ads, no banners, no interstitials, no subscriptions and no
|
||
in-app purchases — there is nothing else to buy, now or later.
|
||
• 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.
|
||
• Sold through Google Play as a one-time purchase — no subscription, no in-app
|
||
purchases, no account.
|
||
• 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`.
|
||
|
||
The price is the one claim that is *not* in here, because it is a Play Console setting
|
||
(`Products > App pricing`) rather than code. What *is* checkable is the rest of the monetisation
|
||
story — the absence of it: no Billing dependency in `gradle/libs.versions.toml`, and no
|
||
`com.android.vending.BILLING` in the merged manifest (see
|
||
[PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5).
|
||
|
||
### 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.
|