docs: cover the tablet screenshot slots and settle app pricing

Two Console-facing subjects, both decided here instead of in the Console.

Tablet screenshots (7-inch and 10-inch)
--------------------------------------
Play keeps a separate screenshot slot per form factor and asks for at least four
screenshots on tablets, so the four phone captures did not cover them. Both sets are now
captured on AOSP Android 12 AVDs, over the same generated 22-photo demo library, and
normalised with FORM_FACTOR=tablet-7in|tablet-10in:

- 7-inch - Nexus 7 2013: 1200x1920 portrait, sw600dp, 320 dpi, four files
- 10-inch - Medium Tablet: 1600x2560 portrait, sw800dp, 320 dpi, four files

This machine has no /dev/kvm - the node cannot even be created, because mknod is refused
for the container as root - so the emulator ran in pure software emulation (-accel off
-no-window -gpu swiftshader_indirect): about 3 minutes to boot, 1-12 s per screenshot.
hide_error_dialogs was switched on and the animation scales off, because under TCG
SystemUI raises an ANR dialog that would otherwise sit in the frame; the 10-inch profile
boots landscape and its portrait rotation is 3, not 0. The scanning frame comes from a
screencap burst started before the tap, since 22 photos scan in seconds, and the tap
coordinates come from uiautomator dumps rather than guesswork.

The results screens are not staged: a finished scan auto-selects all but best, so "14
selected, 43.7 MB" is the default state. 2 duplicate groups, 16 of 22 photos, 43.7 MB
reclaimable, scanned in 39.7 s (7-inch) and 53.1 s (10-inch). Nothing except the demo
library was ever pushed to either AVD and it is deleted again afterwards, so no real
photo can appear in the tablet sets. Screenshots stay out of git, as before.

App pricing: a one-time paid app
--------------------------------
The one irreversible decision in this release. Play's rule: an app can go paid -> free,
but "once your app has been offered for free, the app can't be changed to paid" - a new
package name would be needed - so this had to be settled before the first upload, which
has not happened yet (versionCode 2).

Chosen over free with a Pro unlock because it is the only model that costs the app
nothing: no Billing dependency, no com.android.vending.BILLING permission, no Data
Safety answers, and the "no internet permission at all" claim survives untouched.
Checked rather than assumed: unpacking com.android.billingclient:billing:9.1.0 shows its
manifest declares only com.android.vending.BILLING and the billing service queries, so
in-app purchase would not have added INTERNET either. Billing 8+ is the current
requirement (latest 9.1.0) if that is ever revisited.

Price: base EUR 2.99 with a 14-day 50% launch sale at EUR 1.49. A EUR 1 base price was
considered and rejected: a sale can always be added, but a low anchor cannot be undone,
and a paid app's first transaction is its only transaction. Play's fee for a paid app's
initial purchase is 10% + 5% billing fee (~15%) for the first $1M/year of new-install
transactions in the EEA, so EUR 2.99 nets ~EUR 2.54 against ~EUR 0.85 at EUR 1. The sale
rules shape the launch: 1-14 days live, 30 days between one sale ending and the next
starting, and the app must be published before a sale can be created. The strikethrough
does appear in Germany, which is not one of the 12 EU states and the UK that hide it.

The listing copy had to stop saying "free": the short description is now "Find and remove
duplicate photos. Ad-free, works offline, no uploads." (69/80 chars) and the full
description gains one factual pay-once line plus a "sold through Google Play as a
one-time purchase" bullet (2,426/4,000). The price stays out of the title, icon and
developer name, because Play's metadata policy forbids price and promotional wording
there. PRIVACY.md gains a Purchases section - Google Play processes the payment, the app
has no payment code and no network access at all - and its date moves to 2026-10-14, so
the published copy needs re-pushing to Gitea.

No code changed, so the signed 1.0.1 .aab is untouched (sha256 f8e6a29a...). What is
left is Console work: a payments profile, the price and the launch sale on Products >
App pricing, and a promo-code copy to test the paid listing end to end.
This commit is contained in:
2026-10-14 09:48:57 +02:00
parent 5cb7ad66a8
commit f45e3b79ab
5 changed files with 339 additions and 33 deletions
+222 -11
View File
@@ -12,23 +12,36 @@ release.
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/`) —
captures (`store-assets/raw-screenshots/`) nor the normalised 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.
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
```
```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)
./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)
```
| 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/` |
| 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/` |
Two details worth keeping in mind:
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.
@@ -37,10 +50,19 @@ Two details worth keeping in mind:
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
Captured on the test device (1080×2400, Android 12 / MIUI), cropped to 1080×2160:
### Phone (1080×2160)
Captured on the test device (1080×2400, Android 12 / MIUI), normalised into
`store-assets/screenshots/phone/` at 1080×2160:
| File | Screen |
|---|---|
@@ -73,9 +95,105 @@ 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
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
@@ -148,6 +266,84 @@ Captures are taken by hand, because each of these screens needs a tap and MIUI o
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)
@@ -156,19 +352,26 @@ enough, so the capture never depends on tap timing; that is how `04-options.png`
### Short description (max 80 characters)
Find and remove duplicate photos. Free, ad-free, works offline.
Find and remove duplicate photos. Ad-free, works offline, no uploads.
Alternative, if the no-upload promise should lead:
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
• No ads — no banners, no interstitials, no upsells.
• 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.
@@ -204,6 +407,8 @@ Alternative, if the no-upload promise should lead:
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.
@@ -213,6 +418,12 @@ merged manifest, no ad or analytics dependency in `gradle/libs.versions.toml`, `
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