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:
+13
-2
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user