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
+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 |
| 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** |
| 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 |
| 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 |
| 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, 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)
@@ -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` |
| ~~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 |
| **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) |
| ~~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 |
## 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
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
(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.
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.