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
+13 -2
View File
@@ -1,12 +1,13 @@
# Privacy Policy — Foto Dup Finder # Privacy Policy — Foto Dup Finder
_Last updated: 2026-10-13_ _Last updated: 2026-10-14_
## Summary ## Summary
Foto Dup Finder finds and removes duplicate photos **entirely on your device**. Foto Dup Finder finds and removes duplicate photos **entirely on your device**.
The app does **not** collect, transmit, sell or share any of your data. It has no The app does **not** collect, transmit, sell or share any of your data. It has no
network access at all. network access at all. It is sold once through Google Play; the purchase is handled
entirely by Google Play, never by the app (see [Purchases](#purchases)).
## What the app accesses ## What the app accesses
@@ -51,6 +52,16 @@ that repeat scans are fast. This database contains no image content and never le
your device. Uninstalling the app, or clearing its storage in Android Settings, your device. Uninstalling the app, or clearing its storage in Android Settings,
removes it completely. removes it completely.
## Purchases
The app is **sold once through Google Play**. There is no subscription and there is nothing to buy
inside the app.
Google Play processes the payment and holds the payment details. The app contains no payment code
and never receives them, because it has no network access of any kind (see above). Refunds, order
problems and any question about a charge are handled by Google Play, and the developer can be
reached at the address below.
## Children ## Children
The app is a general-purpose utility and is not directed at children. It collects no The app is a general-purpose utility and is not directed at children. It collects no
+14 -2
View File
@@ -164,8 +164,11 @@ the reclaimable-bytes maths.
./gradlew :app:testDebugUnitTest ./gradlew :app:testDebugUnitTest
``` ```
There is no instrumentation test and no emulator in this environment, so UI There is no instrumentation test, so UI behaviour is verified by installing the debug build on a
behaviour is verified on a real device via `adb install` or Play internal testing. device or an emulator (`adb install -r app/build/outputs/apk/debug/app-debug.apk`) and driving it by
hand or with `adb shell input tap`. That is how the store screenshots are taken; the tablet sets were
shot on AOSP AVDs running in **software emulation**, because this machine exposes no `/dev/kvm`
(see [docs/STORE_LISTING.md](docs/STORE_LISTING.md)).
## Releasing to Google Play ## Releasing to Google Play
@@ -174,9 +177,18 @@ listing, Data Safety, release notes, rollout), [docs/STORE_LISTING.md](docs/STOR
(ready-to-paste listing copy plus the icon / feature graphic generators) and (ready-to-paste listing copy plus the icon / feature graphic generators) and
[PRIVACY.md](PRIVACY.md). [PRIVACY.md](PRIVACY.md).
The app is published as a **one-time paid app** — no ads, no subscription, no in-app purchases, and
therefore no Billing code or `com.android.vending.BILLING` permission in the APK: the price lives on
the listing, not in the build ([why](docs/STORE_LISTING.md#app-pricing)).
The upload-ready graphics and screenshots live in [`store-assets/`](store-assets/). The upload-ready graphics and screenshots live in [`store-assets/`](store-assets/).
`tools/make-store-assets.sh` and `tools/make-store-screenshots.sh` regenerate them from the `tools/make-store-assets.sh` and `tools/make-store-screenshots.sh` regenerate them from the
app's own icon vectors and from `adb` captures — always inside the repo, never in `$HOME`. app's own icon vectors and from `adb` captures — always inside the repo, never in `$HOME`.
Screenshots are kept per form factor (`store-assets/{raw-,}screenshots/{phone,tablet-7in,tablet-10in}/`)
because Play gives phones and each tablet size a slot of their own: `FORM_FACTOR=tablet-7in`
selects the 7-inch pipeline, and the tablet runs deliberately crop no system chrome because a
tablet frame is already inside Play's 2:1 cap. All three sets are captured — the phone set on a real
device, the tablet sets on AOSP AVDs; see [docs/STORE_LISTING.md](docs/STORE_LISTING.md).
`python3 tools/make-demo-photos.py` draws the synthetic photo library the results screenshot is `python3 tools/make-demo-photos.py` draws the synthetic photo library the results screenshot is
captured over, so the listing never shows a real one. captured over, so the listing never shows a real one.
+78 -13
View File
@@ -75,15 +75,43 @@ Ready-to-paste copy and the generated graphics live in
[docs/STORE_LISTING.md](STORE_LISTING.md) (milestone M10). Everything below is a Console [docs/STORE_LISTING.md](STORE_LISTING.md) (milestone M10). Everything below is a Console
action except the items marked **[code]**. action except the items marked **[code]**.
- [ ] App name (≤ 30 chars): "Foto Dup Finder". - [ ] ⚠️ **App pricing — do this before the first upload; it is the one irreversible decision.**
Play, *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."* This release has not been
uploaded yet (`versionCode` 2 is the first), so a paid app is still possible; after the first
upload to any track it is not. In the Console:
1. **Set up a payments profile** with your tax rates (Play asks the first time you set a price).
2. **Products > App pricing** → *"Your app is"* → **Make your app paid**, and enter the base price
**€2.99** (one-time). Leave *"Default price includes tax"* off unless €2.99 is meant to be the
final gross price in the EU — off, a German buyer pays €3.56.
3. Check the **local prices** Play derives for each country — a country with no price is a
country where the app is unavailable.
4. [ ] **Launch sale: 50% off (€1.49) for 14 days.** A sale can only be created *after* the app is
published (*App pricing > Sales > Create sale*), it can be **live for 1–14 days at most**, and
**30 days must pass between one sale ending and the next starting** — so this is a single
deadline-driven window, not a permanently lower launch price. Percentage discounts must be
between 10% and 100%. The original price is struck through everywhere except in 12 EU member
states and the UK (Germany does show it).
5. [ ] **Test the paid listing end to end**: install your own copy through a **promo code**
rather than a real purchase. A giveaway code is the only trial mechanism a paid app has, and
it doubles as the check that a buyer really does receive a working app.
**[code]** nothing to build: no Billing dependency, no `com.android.vending.BILLING` in the
merged manifest. Rationale, the fee arithmetic and the sale rules:
[STORE_LISTING](STORE_LISTING.md) › App pricing.
- [ ] App name (≤ 30 chars): "Foto Dup Finder" (15 chars).
- [ ] Short description (≤ 80 chars) — two options are drafted and length-checked in - [ ] Short description (≤ 80 chars) — two options are drafted and length-checked in
STORE_LISTING.md. STORE_LISTING.md (69 and 65 chars). **Neither says "free"**: the app is paid, so the claim would
- [ ] Full description (≤ 4000 chars) — drafted and length-checked in STORE_LISTING.md. be false, and Play's metadata policy keeps price and promotional wording out of the title,
icon and developer name.
- [ ] Full description (≤ 4000 chars) — drafted and length-checked in STORE_LISTING.md (2 426 chars,
so there is room to grow). The one price line ("Pay once, then own it…") sits in the body for
the same policy reason.
- [x] **App icon** 512×512 PNG **[code]** — `tools/make-store-assets.sh` renders it from the - [x] **App icon** 512×512 PNG **[code]** — `tools/make-store-assets.sh` renders it from the
same vectors as the adaptive icon in `res/drawable/ic_launcher_*`. same vectors as the adaptive icon in `res/drawable/ic_launcher_*`.
- [x] **Feature graphic** 1024×500 PNG **[code]** — same script. - [x] **Feature graphic** 1024×500 PNG **[code]** — same script.
- [x] **Screenshots** (at least 2, up to 8) **[code]** — four are captured and normalised to - [x] **Phone screenshots** (at least 2, up to 8) **[code]** — four are captured and normalised to
1080×2160 in `store-assets/screenshots/` (git-ignored): empty state, scanning 1080×2160 in `store-assets/screenshots/phone/` (git-ignored): empty state, scanning
(`5353 / 5385`), results (**17 duplicate groups, 54 of 5385 photos, 83.5 MB reclaimable**) (`5353 / 5385`), results (**17 duplicate groups, 54 of 5385 photos, 83.5 MB reclaimable**)
and the Options menu. They were shot after a real permission grant and over the generated and the Options menu. They were shot after a real permission grant and over the generated
demo library (`tools/make-demo-photos.py`), so neither the "Photo access needed" banner nor demo library (`tools/make-demo-photos.py`), so neither the "Photo access needed" banner nor
@@ -94,6 +122,24 @@ action except the items marked **[code]**.
⚠️ Play rejects a screenshot whose longer side is more than 2× its shorter side, so a ⚠️ Play rejects a screenshot whose longer side is more than 2× its shorter side, so a
1080×2400 phone capture has to be cropped to 1080×2160; 1080×2400 phone capture has to be cropped to 1080×2160;
`tools/make-store-screenshots.sh` does exactly that (status and navigation bars only). `tools/make-store-screenshots.sh` does exactly that (status and navigation bars only).
- [x] **7-inch tablet screenshots** **[code]** — captured on a `Nexus 7 2013` AOSP Android 12 AVD
(1200×1920 portrait, `sw600dp`) and normalised to `store-assets/screenshots/tablet-7in/`
(git-ignored) with `FORM_FACTOR=tablet-7in ./tools/make-store-screenshots.sh`: the same four
screens as the phone set, over the same generated demo library — empty state, scanning
(*“Looking for visually similar photos…”* 20 / 22), results (**2 duplicate groups, 16 of 22
photos, 43.7 MB reclaimable**, scanned in 39.7 s) and the Options menu (✓ on *Balanced*).
Play asks for **at least 4 screenshots** on tablets, so four is the floor, not the target.
📋 A tablet frame is already inside Play's 2:1 cap, so this form factor deliberately crops
**0 px** of system chrome; the tablet captures are the raw screencaps, flattened to 24-bit.
- [x] **10-inch tablet screenshots** **[code]** — same run on a `medium_tablet` AOSP Android 12 AVD
(1600×2560 portrait, `sw800dp`), normalised into `store-assets/screenshots/tablet-10in/` with
`FORM_FACTOR=tablet-10in`: scanning at 20 / 22 and the same **2 groups · 16 of 22 photos ·
43.7 MB reclaimable** results screen, scanned in 53.1 s.
⚠️ Both AVDs ran in **pure software emulation**, because this machine exposes no `/dev/kvm`
(`-accel off -no-window -gpu swiftshader_indirect`): ≈3 min to boot and 1–12 s per screenshot.
`hide_error_dialogs` was turned on and animations off, because under software emulation SystemUI
raises an ANR dialog that would otherwise sit in frame. Full recipe:
[STORE_LISTING](STORE_LISTING.md) › Screenshots › 7-inch and 10-inch tablets.
- [ ] Category: *Tools* (or *Photography* — pick it **before** the tags, because it shapes the - [ ] Category: *Tools* (or *Photography* — pick it **before** the tags, because it shapes the
*Suggested tags* list). Tags: **max 5, from Play's own list, not typed freely — and only two *Suggested tags* list). Tags: **max 5, from Play's own list, not typed freely — and only two
are honest for this app: *Photography*, plus optionally *Privacy & security*.** Play's are honest for this app: *Photography*, plus optionally *Privacy & security*.** Play's
@@ -129,6 +175,13 @@ action except the items marked **[code]**.
- *Is all user data encrypted in transit?* → N/A (no transmission). - *Is all user data encrypted in transit?* → N/A (no transmission).
- *Do you provide a way for users to request data deletion?* → Uninstalling - *Do you provide a way for users to request data deletion?* → Uninstalling
removes all app data. removes all app data.
- The **paid** model changes none of the above: the purchase happens in the Play Store app, not in
this one. Play's own guidance is that using a payment service such as *"Google Play's billing
system"* to complete transactions does **not** require declaring the data that service collects
in connection with its processing — and this app contains no payment code at all, so it receives
nothing either way. Re-read that if a Billing dependency is ever added
(step 9); the app's own answers would still be "no", but the *purchase handling* would move
into the app and the answers would need a fresh look.
- [x] Confirm the **permission set** the Console will show. **[code]** done 2026-10-13: `aapt2 dump - [x] Confirm the **permission set** the Console will show. **[code]** done 2026-10-13: `aapt2 dump
badging` on the release APK reports exactly the table below (plus badging` on the release APK reports exactly the table below (plus
`…DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`) — and **no `INTERNET`**. Re-check from the `…DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`) — and **no `INTERNET`**. Re-check from the
@@ -282,13 +335,25 @@ Ready-to-paste block for **1.0.1** (the scan-robustness patch):
- [ ] Optionally add crash reporting later — but note this would change the Data - [ ] Optionally add crash reporting later — but note this would change the Data
safety answers, so weigh it against the "no data leaves the device" promise. safety answers, so weigh it against the "no data leaves the device" promise.
## 9. Optional monetisation ## 9. Monetisation — decided: one-time paid app, no billing
The app currently has no ads and no billing, which is a deliberate differentiator. **The decision is made: Foto Dup Finder is a one-time paid app** (rationale, the €2.99 base price,
If you later add a paid tier, use **Play Billing** (e.g. a one-time "Pro" the €1.49 launch sale and the irreversibility rule: [STORE_LISTING](STORE_LISTING.md) › App pricing).
purchase) and update: The Console work is in step 4; **nothing in this repository changes**, because a price on the listing
- the Data safety form (purchase history), adds no code, no permission and no Data Safety answer — which is exactly what a free app with a Pro
- the privacy policy, unlock could not claim.
- the store description.
Avoid ad SDKs entirely — they would contradict the app's main selling point. Standing rules:
- **Ads: never.** Every ad SDK needs the `INTERNET` permission to fetch anything, and the
merged-manifest check in step 5, [PRIVACY.md](../PRIVACY.md) and the Data Safety answers all rest
on that permission being absent.
- **In-app purchases: deliberately absent.** If that ever changes, the requirement is Play Billing
**8+** (latest published: 9.1.0, with an extension available to Nov 1, 2026). Worth knowing before
the decision is re-litigated: the **Billing Library itself does not add `INTERNET`** —
`com.android.billingclient:billing:9.1.0`'s manifest declares only `com.android.vending.BILLING`
and `<queries>` for the billing service, which was checked by unpacking the AAR. What *would*
change is the store copy (the "pay once" line), `PRIVACY.md`, the acknowledgement/restore flow, and
the fact that a purchase can only be verified on an internal testing track — never locally.
- **Refunds and support** follow Play's policy for a paid app; keep the contact address in
[PRIVACY.md](../PRIVACY.md) current.
+12 -5
View File
@@ -20,8 +20,8 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| M6 | Release config & Play readiness | ✅ | R8/minify, optional signing, `.aab`, [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md), [PRIVACY.md](../PRIVACY.md), README | | M6 | Release config & Play readiness | ✅ | R8/minify, optional signing, `.aab`, [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md), [PRIVACY.md](../PRIVACY.md), README |
| M7 | On-device validation & hardening | ✅ | Foreground `dataSync` service, one failure boundary per file, `core/DecodeMath`, hostile fixtures ([EDGE_CASE_TESTING](EDGE_CASE_TESTING.md)), `versionName` → 1.0.0 | | M7 | On-device validation & hardening | ✅ | Foreground `dataSync` service, one failure boundary per file, `core/DecodeMath`, hostile fixtures ([EDGE_CASE_TESTING](EDGE_CASE_TESTING.md)), `versionName` → 1.0.0 |
| M8 | Localisation groundwork | ✅ | Every user-visible string in `res/values/strings.xml` with real `<plurals>`, shared `text/TextRes.kt`; **English is the only locale** | | M8 | Localisation groundwork | ✅ | Every user-visible string in `res/values/strings.xml` with real `<plurals>`, shared `text/TextRes.kt`; **English is the only locale** |
| M9 | Monetisation | ⏳ | No billing code. Blocked on a product decision (free forever vs. one-time unlock vs. limited free scans); changes the Data Safety answers | | M9 | Monetisation | ✅ | **Decided: a one-time paid app** — no free tier, no trial, no subscription and no in-app purchase, so there is **no Billing code** and no new permission. Checked before deciding: `com.android.billingclient:billing:9.1.0`'s manifest declares only `com.android.vending.BILLING`, so IAP would *not* have added `INTERNET` either — but a price on the listing is the one model that changes nothing at all about the "no internet permission" claim. Base price **€2.99** one-time with a **50% launch sale at €1.49**, both set in the Console (*Products > App pricing*). €1 as the *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. Free → paid becomes impossible after the first upload, so this had to be settled before release. Rationale, copy changes and Console steps: [STORE_LISTING](STORE_LISTING.md) › App pricing, [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 4 |
| M10 | Store assets | ✅ | `tools/make-store-assets.sh` renders the 512×512 icon and the 1024×500 feature graphic from the app's own vectors; `tools/make-store-screenshots.sh` normalises device captures to Play's 2:1 limit (1080×2160); `tools/make-demo-photos.py` generates the photo library the results capture is taken over, so no real photos are published; all listing copy is drafted and length-checked in [STORE_LISTING](STORE_LISTING.md). Four screenshots (empty, scanning, results, Options) were re-shot after a real permission grant and normalised to 1080×2160; screenshots are deliberately not committed | | M10 | Store assets | ✅ | `tools/make-store-assets.sh` renders the 512×512 icon and the 1024×500 feature graphic from the app's own vectors; `tools/make-store-screenshots.sh` normalises device captures to Play's 2:1 limit, one directory per form factor (`phone` → 1080×2160, `tablet-7in` → 1200×1920, `tablet-10in` → 1600×2560); `tools/make-demo-photos.py` generates the photo library the results captures are taken over, so no real photos are published; all listing copy is drafted and length-checked in [STORE_LISTING](STORE_LISTING.md). The phone set was re-shot on a real device after a real permission grant, and the two tablet sets were captured on AOSP AVDs (see the blocker below); screenshots are deliberately not committed |
## Release blockers (work that is not a numbered milestone) ## Release blockers (work that is not a numbered milestone)
@@ -30,8 +30,9 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| ~~Large-library scan stall~~ ✅ **fixed — verified on device** | The 5,363-photo library used to freeze at *“74 / 100 · Comparing file signatures…”* (a **blocked syscall**: zero CPU, workers sleeping). `BoundedCall` now abandons a stuck provider call **and** trips a stall breaker after three consecutive timeouts. **Verified on the real test device (Android 12 / MIUI): the scan completes — 20.2 s cold, 11.0 s warm, 15 groups, 6 unreadable — where it used to hang.** Covered by `BoundedCallTest` and `ScanEngineEndToEndTest` | | ~~Large-library scan stall~~ ✅ **fixed — verified on device** | The 5,363-photo library used to freeze at *“74 / 100 · Comparing file signatures…”* (a **blocked syscall**: zero CPU, workers sleeping). `BoundedCall` now abandons a stuck provider call **and** trips a stall breaker after three consecutive timeouts. **Verified on the real test device (Android 12 / MIUI): the scan completes — 20.2 s cold, 11.0 s warm, 15 groups, 6 unreadable — where it used to hang.** Covered by `BoundedCallTest` and `ScanEngineEndToEndTest` |
| ~~Unreleased `BoundedCall` fix~~ ✅ | The provider-call deadline shipped in **1.0.1** (`versionCode` 2) | | ~~Unreleased `BoundedCall` fix~~ ✅ | The provider-call deadline shipped in **1.0.1** (`versionCode` 2) |
| **Play Console: foreground-service declaration** | Declaring the `dataSync` type (App content › Foreground service permissions) is release-blocking. **[code]** the manifest already declares it; the ready-to-paste justification is in [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5 — this is a Console action, not a code change | | **Play Console: foreground-service declaration** | Declaring the `dataSync` type (App content › Foreground service permissions) is release-blocking. **[code]** the manifest already declares it; the ready-to-paste justification is in [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5 — this is a Console action, not a code change |
| **Remaining Play paperwork** | Console actions only, no code: the **first upload + Play App Signing enrolment**, the **hosted privacy policy URL** (the working copy is now published to Gitea as [`fishfacegit/foto-duplicate-finder`](https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder), so the policy renders at a public, login-free URL and only the Console paste is left), **Data safety**, the **Photo/video permissions** declaration, the **content rating / audience / app access / ads / export** questions, and the **listing copy + four screenshots**. Keystore, `versionCode`, listing copy, graphics, permission audit and the signed `.aab` (`sha256 f8e6a29a…`) are all in place; rollout goes internal → closed → production. Step-by-step: [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) | | **Remaining Play paperwork** | Console actions only, no code: the **first upload + Play App Signing enrolment**, the **hosted privacy policy URL** (the working copy is now published to Gitea as [`fishfacegit/foto-duplicate-finder`](https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder), so the policy renders at a public, login-free URL and only the Console paste is left), **Data safety**, the **Photo/video permissions** declaration, the **content rating / audience / app access / ads / export** questions, and the **listing copy + the three screenshot sets** (phone, 7-inch and 10-inch, all captured), plus the **paid-app setup**: a payments profile with tax rates, the €2.99 price and the 14-day €1.49 launch sale on *Products > App pricing* — the price must be set before the first upload, because free → paid is not possible afterwards (M9). Keystore, `versionCode`, listing copy, graphics, permission audit and the signed `.aab` (`sha256 f8e6a29a…`) are all in place; rollout goes internal → closed → production. Step-by-step: [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) |
| ~~Store screenshots need one re-shoot~~ ✅ **done — verified on device** | The old captures showed the *Photo access needed* banner (an app-op is not a grant) and `03-results.png` showed the developer's own library. After a real grant the set was re-shot over the generated demo library: **01-empty** (no tap needed — results and selection live in `ScannerViewModel`, so force-stop + relaunch returns to the empty state with the grant still in place), **02-scanning** (`5353 / 5385`), **03-results** (**17 groups, 54 of 5385 photos, 83.5 MB reclaimable**; the headline group's 43.1 MB out-ranks the device's own 39.8 MB, so no real photo is in frame) and **04-options**. Captures are taken by watching for UI **text** changes, because MIUI refuses synthetic taps (`INJECT_EVENTS`). The demo library is deleted from the test device afterwards — MediaStore **5,386 → 5,364** rows, and the 22 stale rows it leaves in the hash index are pruned by `PhotoIndexStore.pruneTo` on the next scan. Deliberately not captured: the staged state and the system confirmation dialog, because staging clears the demo group and would put real photos back in frame. Details: [STORE_LISTING](STORE_LISTING.md) | | ~~Store screenshots need one re-shoot~~ ✅ **done — verified on device** | The old captures showed the *Photo access needed* banner (an app-op is not a grant) and `03-results.png` showed the developer's own library. After a real grant the set was re-shot over the generated demo library: **01-empty** (no tap needed — results and selection live in `ScannerViewModel`, so force-stop + relaunch returns to the empty state with the grant still in place), **02-scanning** (`5353 / 5385`), **03-results** (**17 groups, 54 of 5385 photos, 83.5 MB reclaimable**; the headline group's 43.1 MB out-ranks the device's own 39.8 MB, so no real photo is in frame) and **04-options**. Captures are taken by watching for UI **text** changes, because MIUI refuses synthetic taps (`INJECT_EVENTS`). The demo library is deleted from the test device afterwards — MediaStore **5,386 → 5,364** rows, and the 22 stale rows it leaves in the hash index are pruned by `PhotoIndexStore.pruneTo` on the next scan. Deliberately not captured: the staged state and the system confirmation dialog, because staging clears the demo group and would put real photos back in frame. Details: [STORE_LISTING](STORE_LISTING.md) |
| ~~Tablet store screenshots outstanding~~ ✅ **done — captured on AVDs** | Play has a **7-inch tablet** and a **10-inch tablet** screenshot slot of its own (asking for **≥ 4** screenshots on large screens). Both are now captured on AOSP Android 12 AVDs — `Nexus 7 2013` (1200×1920, `sw600dp`) and `medium_tablet` (1600×2560, `sw800dp`) — because this machine exposes no `/dev/kvm`: they ran in **pure software emulation** (`-accel off -no-window -gpu swiftshader_indirect`), ≈3 min to boot, with `hide_error_dialogs` on so SystemUI's ANR dialog could not sit in frame. Four screens per tablet over the same generated demo library: empty, scanning (20 / 22), results (**2 groups · 16 of 22 photos · 43.7 MB reclaimable**, 39.7 s / 53.1 s) and Options. `tools/make-store-screenshots.sh` is form-factor aware (`FORM_FACTOR=phone\|tablet-7in\|tablet-10in`) and the tablet form factors crop **0 px** of chrome, because a tablet frame is already inside Play's 2:1 cap. Recipe: [STORE_LISTING](STORE_LISTING.md) |
| **Device-only verifications** | RAW/DNG files, a *real* iPhone HEIC with a non-upright EXIF orientation, permission revoked mid-scan, and the Android 14 “Selected photos” flow. ✅ The **index-reuse** check is done: after a scan the index holds **5,363 rows, 5,357 with a perceptual hash**, and a second scan runs in **11.0 s vs 20.2 s** cold | | **Device-only verifications** | RAW/DNG files, a *real* iPhone HEIC with a non-upright EXIF orientation, permission revoked mid-scan, and the Android 14 “Selected photos” flow. ✅ The **index-reuse** check is done: after a scan the index holds **5,363 rows, 5,357 with a perceptual hash**, and a second scan runs in **11.0 s vs 20.2 s** cold |
## Candidate milestones (proposed, not scheduled) ## Candidate milestones (proposed, not scheduled)
@@ -52,8 +53,14 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
app's own vectors, the screenshot normaliser enforces Play's 2:1 limit, the listing copy is app's own vectors, the screenshot normaliser enforces Play's 2:1 limit, the listing copy is
drafted and length-checked, and the demo library that keeps real photos out of the results drafted and length-checked, and the demo library that keeps real photos out of the results
capture is generated and validated. The re-shoot after a real permission grant is done as well capture is generated and validated. The re-shoot after a real permission grant is done as well
(see blockers): four captures, normalised to 1080×2160. (see blockers): four captures, normalised to 1080×2160. The normaliser is form-factor aware too,
and the 7-inch and 10-inch tablet sets were captured on AOSP AVDs in software emulation
(1200×1920 and 1600×2560), so all three screenshot sets now exist.
4. **Colour signature** — the flat-colour false-positive fix. 4. **Colour signature** — the flat-colour false-positive fix.
5. **M9 — monetisation**, once the product decision is made. 5. ✅ **Done** — **M9 monetisation**: decided as a **one-time paid app (€2.99, launched with a 14-day
50% sale at €1.49)**, which needs no code at all. The remaining work is Console-only — a payments
profile, the price and the launch sale on *Products > App pricing* — and the price must happen
before the first upload, because free → paid stops being possible once the app has been offered
for free.
Deliberately deferred: **video support** and the **“similar shots”** section. Deliberately deferred: **video support** and the **“similar shots”** section.
+220 -9
View File
@@ -12,13 +12,24 @@ release.
Generated into `store-assets/` **inside the repository**, never into `$HOME`. The icon and the 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 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 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 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. 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 ```bash
./tools/make-store-assets.sh # icon + feature graphic -> store-assets/ ./tools/make-store-assets.sh # icon + feature graphic -> store-assets/
./tools/make-store-screenshots.sh # crop the adb captures -> store-assets/screenshots/ ./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) python3 tools/make-demo-photos.py # synthetic photo library -> demo-photos/ (git-ignored)
``` ```
@@ -26,9 +37,11 @@ python3 tools/make-demo-photos.py # synthetic photo library -> demo-photos/ (gi
|---|---|---|---| |---|---|---|---|
| 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) | | 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` | | 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 - 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. 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 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 app content. Re-measure both insets if you shoot on another device and pass
`STATUS_BAR_PX` / `NAV_BAR_PX`. `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 ## 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 | | File | Screen |
|---|---| |---|---|
@@ -73,9 +95,105 @@ returns to the empty state with the grant still in place:
```bash ```bash
adb shell am force-stop de.softwareapp_hb.fotodup.finder.debug 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 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 ### 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 `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 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. 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 ## Store listing copy
### App name (max 30 characters) ### 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) ### 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. 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) ### Full description (max 4000 characters)
Find and remove duplicate photos, then get the space back. Foto Dup Finder does one 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. job and does it without ads, without an account and without ever uploading anything.
WHY IT IS DIFFERENT 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 internet permission at all, so the app has no way to upload your photos.
• No account, no sign-in, no tracking, no analytics. • No account, no sign-in, no tracking, no analytics.
@@ -204,6 +407,8 @@ Alternative, if the no-upload promise should lead:
GOOD TO KNOW GOOD TO KNOW
• Requires Android 8.0 or newer. • 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 • Photos moved to the trash can be restored from your gallery app until the trash is
emptied (Android 11 and newer). emptied (Android 11 and newer).
• Works fully offline, in flight mode, always. • 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 in `app/build.gradle.kts`, and the second-run speed-up is the persistent hash index in
`data/PhotoIndexStore.kt`. `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 and tags
- **Category:** Tools (alternative: Photography — *Tools* matches the one-job utility framing - **Category:** Tools (alternative: Photography — *Tools* matches the one-job utility framing