docs: finish the Console pre-flight and retire the screenshot blocker

Everything the Play forms ask for that can be settled from the working copy, settled:

- PRIVACY.md now carries the real contact address (softwareapp.hb@gmail.com), so the
  policy publishes as-is. Hosting is the one step left that cannot be done from here,
  because the repo has no git remote: step 5 of the checklist spells out the push +
  GitHub Pages route and the URL it yields.
- Checklist: the screenshot item is done (four captures, 1080x2160, over the demo
  library, no permission banner), the keystore and versionCode are recorded as already
  in place - including the upload key's SHA-256 for the Play App Signing enrolment - and
  the App access / Ads / remaining App content declarations are written down. The
  first-upload release-notes question is answered too: versionCode is already 2, so
  1.0.1 takes the *initial release* block, not the 1.0.1 one.
- The build facts were verified against the artefacts rather than the source: the
  release APK reports versionCode=2, versionName=1.0.1, targetSdk=36 and exactly the
  documented permission set, with no INTERNET in what actually ships.
- ROADMAP: the re-shoot row is struck out with the numbers that closed it, M10 no longer
  claims the captures predate the demo library, and the demo library's removal is
  recorded (MediaStore 5,386 -> 5,364; pruneTo drops the stale index rows next scan).

The staged state and the system confirmation dialog are documented as deliberately not
captured: staging clears the demo group, which would put real photos back in frame.
This commit is contained in:
2026-10-13 23:59:15 +02:00
parent b43718aca8
commit 55101886cb
4 changed files with 80 additions and 39 deletions
+1 -2
View File
@@ -63,5 +63,4 @@ new "last updated" date.
## Contact ## Contact
Questions about this policy: **support@example.com** _(replace with your real Questions about this policy: **softwareapp.hb@gmail.com**.
support address before publishing)_.
+59 -23
View File
@@ -9,24 +9,28 @@ the rest are things you must do in the Play Console / with your own accounts.
(`applicationId` in `app/build.gradle.kts`). It can **never be changed after the (`applicationId` in `app/build.gradle.kts`). It can **never be changed after the
first release**, so treat it as locked. `namespace` and the Kotlin package of first release**, so treat it as locked. `namespace` and the Kotlin package of
the same name are aligned with it; debug builds append `.debug`. the same name are aligned with it; debug builds append `.debug`.
- [ ] Generate an **upload keystore** (keep it safe; if you lose it you cannot - [x] Generate an **upload keystore** (keep it safe; if you lose it you cannot
publish updates): publish updates) — **[code]** done: `fotodup-upload.jks` in the project root
```bash (git-ignored, `alias upload`), `CN=Hadrian Burkhardt, L=Potsdam, C=DE`.
keytool -genkeypair -v -keystore upload-keystore.jks \ - [x] Copy `keystore.properties.example` to `keystore.properties` and fill it in.
-alias upload -keyalg RSA -keysize 4096 -validity 10000 **[code]** done — the file is in place, and the Gradle build picks it up automatically and
``` signs release builds; without it the release APK stays unsigned.
- [ ] Copy `keystore.properties.example` to `keystore.properties` and fill it in.
**[code]** The Gradle build picks it up automatically and signs release
builds; without it the release APK stays unsigned.
- [ ] Enable **Play App Signing** in the Play Console and upload the certificate of - [ ] Enable **Play App Signing** in the Play Console and upload the certificate of
your *upload* key. your *upload* key:
```bash
keytool -exportcert -alias upload -keystore fotodup-upload.jks -rfc > upload_cert.pem
```
Upload key fingerprint (SHA-256), to confirm the right key was registered:
`AA:8C:3D:6C:D0:55:46:50:48:ED:04:3E:AF:34:2B:66:B4:13:87:C9:E0:DA:29:70:5A:0D:18:47:08:1A:4C:7A`.
Enrolment happens with the first upload: Google then holds the app signing key and this
one only signs uploads.
- [ ] Store the keystore + passwords in a password manager / secure vault. Back it up. - [ ] Store the keystore + passwords in a password manager / secure vault. Back it up.
## 2. Versioning ## 2. Versioning
- [ ] Set `versionName` (user-visible, e.g. `1.0.0`) **[code]** in - [x] Set `versionName` (user-visible) **[code]** — `1.0.1` in `app/build.gradle.kts`.
`app/build.gradle.kts`. - [x] `versionCode` **[code]** — currently **2**. It is the counter Play orders uploads by, so the
- [ ] Increment `versionCode` for **every** upload. first upload will be 2 and every later upload must be higher.
## 3. Build the release artefacts ## 3. Build the release artefacts
@@ -35,6 +39,12 @@ the rest are things you must do in the Play Console / with your own accounts.
./gradlew :app:assembleRelease # -> APK for local testing ./gradlew :app:assembleRelease # -> APK for local testing
``` ```
- [x] **[code]** verified 2026-10-13: `bundleRelease` and `assembleRelease` both succeed.
`app/build/outputs/bundle/release/app-release.aab` is 2,944,100 bytes
(`sha256 f8e6a29a0daf60d025409e19be878c3e8f1359a06b3f9fca300bb4aec9ebb726`) and is signed with
the upload key. The release APK reports `versionCode=2 versionName=1.0.1 targetSdk=36` and
exactly the permission set listed in step 5 — **no `INTERNET`**, so "nothing leaves the
device" holds in the shipped artefact, not just in the source manifest.
- [ ] Upload the **`.aab`** (not the APK) to the Play Console. - [ ] Upload the **`.aab`** (not the APK) to the Play Console.
- [ ] Keep `app/build/outputs/mapping/release/mapping.txt` for every release so you - [ ] Keep `app/build/outputs/mapping/release/mapping.txt` for every release so you
can de-obfuscate crash reports (upload it under *Release > App bundle explorer*). can de-obfuscate crash reports (upload it under *Release > App bundle explorer*).
@@ -52,12 +62,15 @@ action except the items marked **[code]**.
- [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.
- [ ] At least 2 phone screenshots (up to 8). Three are captured (empty state, scanning, - [x] **Screenshots** (at least 2, up to 8) **[code]** — four are captured and normalised to
results), but **re-shoot them after granting photo access on a fresh install**: the 1080×2160 in `store-assets/screenshots/` (git-ignored): empty state, scanning
existing ones still show the "Photo access needed" banner, and the results capture shows (`5353 / 5385`), results (**17 duplicate groups, 54 of 5385 photos, 83.5 MB reclaimable**)
the developer's own photo library. Capture the results screen over the generated demo and the Options menu. They were shot after a real permission grant and over the generated
library instead (`python3 tools/make-demo-photos.py`, then push and index it) — see the demo library (`tools/make-demo-photos.py`), so neither the "Photo access needed" banner nor
caveats in STORE_LISTING.md. a personal photo is in frame; the demo library is deleted from the test device again once
the captures are done.
Upload the four, then drag **03-results** to first place — the results screen is the one
that sells, and the empty state is the weakest opener.
⚠️ 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).
@@ -66,10 +79,22 @@ action except the items marked **[code]**.
## 5. Policy and compliance ## 5. Policy and compliance
- [ ] Host [PRIVACY.md](../PRIVACY.md) at a public URL and paste that URL into the - [ ] Host [PRIVACY.md](../PRIVACY.md) at a public URL and paste that URL into the
Play Console. **This is mandatory** because the app accesses photos. Play Console. **This is mandatory** because the app accesses photos. The contact address
⚠️ **Before hosting:** replace the `support@example.com` placeholder in the in it is final (`softwareapp.hb@gmail.com`), so the file publishes as-is.
*Contact* section with a real address — a policy whose contact is a placeholder is a This is the one item here that cannot be finished from the working copy, because the repo
rejection risk. has **no git remote** yet. Shortest route:
1. create a public repository and push this working copy:
```bash
git remote add origin git@github.com:<user>/foto-dup-finder.git
git push -u origin main
```
2. **Settings › Pages › Source: Deploy from a branch**, branch `main`, folder `/ (root)`;
3. the policy then renders at `https://<user>.github.io/foto-dup-finder/PRIVACY.html` —
open that in a private window to prove it needs no login, then paste it into
**App content › Privacy policy**.
Any static host works the same way; the requirement is only that the URL is public, stable
and readable without an account.
> **Note on Play's automatic translation:** it translates the *store listing*, not > **Note on Play's automatic translation:** it translates the *store listing*, not
> the hosted policy. If you want the policy in another language, host a translated > the hosted policy. If you want the policy in another language, host a translated
@@ -141,7 +166,13 @@ action except the items marked **[code]**.
the manifest, it has to be declared here too. the manifest, it has to be declared here too.
- [ ] Complete the **content rating** questionnaire (no objectionable content → - [ ] Complete the **content rating** questionnaire (no objectionable content →
typically rated Everyone / 3+). typically rated Everyone / 3+).
- [ ] **App access** → *All functionality is available without special access*: no login, no
account, nothing gated.
- [ ] **Ads** → *No, my app does not contain ads* (and no ad SDK in
`gradle/libs.versions.toml` to contradict the answer).
- [ ] Select the target audience (not designed for children). - [ ] Select the target audience (not designed for children).
- [ ] Answer the remaining *App content* declarations **No** / not applicable: news app,
government app, financial features, health, COVID-19 contact tracing.
- [ ] Accept the *Developer Program Policies* and *US export laws* declarations. - [ ] Accept the *Developer Program Policies* and *US export laws* declarations.
## 6. Testing before production ## 6. Testing before production
@@ -164,6 +195,11 @@ action except the items marked **[code]**.
## 7. Release notes ("What's new") and rollout ## 7. Release notes ("What's new") and rollout
⚠️ **Which block applies first:** `versionCode` is already **2** (`versionName` 1.0.1), because
1.0.1 was cut before anything reached Play — so the *first* upload is 1.0.1 and takes the
**initial release** block. The 1.0.1 block below applies only once 1.0.0 exists on a track.
Release notes are **not stored in this repo** — they are typed into the Play Console Release notes are **not stored in this repo** — they are typed into the Play Console
for each release. for each release.
+4 -4
View File
@@ -21,7 +21,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| 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 | ⏳ | 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). The captures currently on disk predate the demo library and still show the permission banner — see blockers; 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 (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 |
## Release blockers (work that is not a numbered milestone) ## Release blockers (work that is not a numbered milestone)
@@ -31,7 +31,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| ~~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** | Keystore / Play App Signing, Data Safety form, hosted privacy policy and store listing — see [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) | | **Remaining Play paperwork** | Keystore / Play App Signing, Data Safety form, hosted privacy policy and store listing — see [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) |
| **Store screenshots need one re-shoot** | Two things are wrong with the captures currently in `store-assets/screenshots/`: they were taken with the debug-only `MANAGE_EXTERNAL_STORAGE` app-op, so the app's own permission check (correctly) still reported *no access* and the banner is in frame; and `03-results.png` shows the developer's real photo library. Both clear in a single run after tapping **Grant access**. ⚠️ An app-op cannot stand in for the grant — `appops set --uid <pkg> android:read_media_images allow` leaves the banner *and* a disabled *Start scan* — and MIUI refuses synthetic taps, so the re-shoot needs two real taps unless *USB debugging (Security settings)* is enabled in the developer options. The demo library that keeps real photos out of the capture is generated, validated and already indexed on the test device; files, specs and caveats: [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) |
| **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)
@@ -51,8 +51,8 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
3. ✅ **Done** — **M10 store assets**: the icon and feature graphic are generated from the 3. ✅ **Done** — **M10 store assets**: the icon and feature graphic are generated from the
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 only remainder is the re-shoot after a real capture is generated and validated. The re-shoot after a real permission grant is done as well
permission grant (see blockers). (see blockers): four captures, normalised to 1080×2160.
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. **M9 — monetisation**, once the product decision is made.
+16 -10
View File
@@ -132,15 +132,21 @@ 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 adb shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo
``` ```
Two more screens are worth adding to the set. Each needs a tap, and MIUI only accepts taps from a This was run on the test device as soon as the four captures were taken: MediaStore went from
human, so they are captured by hand — a loop that takes a `uiautomator dump` and screencaps whenever **5,386 to 5,364** image rows and the 22 demo rows are gone. The matching rows in the app's own
the UI **text** changes is enough, so the capture never depends on tap timing. That is how hash index are dropped by `PhotoIndexStore.pruneTo` on the next scan — the index is keyed by
`04-options.png` was taken, and it catches the menu, the staging and a system dialog in one pass: MediaStore id, so there is nothing else to reset.
- the staged state (*“All duplicates are ready to go”*) after *Remove all selected*. Nothing is Two further screens were weighed up and deliberately left out of the set: the staged state (*“All
deleted at that point and no dialog appears yet — the rows simply leave the list. duplicates are ready to go”*) after *Remove all selected*, and Android's confirmation dialog for the
- Android's **confirmation dialog** for the whole batch, which *Apply* launches. Dismissing it is batch that *Apply* launches. Staging every selected row also clears the demo group out of the list,
non-destructive: the staging is kept, and *Apply* can be tapped again. which promotes the device's own 39.8 MB group into frame — so those captures could not be published,
and since screenshots are not committed there would be nothing to keep them for. (Dismissing the
dialog is non-destructive, so nothing is lost by not going that far.)
Captures are taken by hand, because each of these screens needs a tap and MIUI only accepts taps from
a human. A loop that takes a `uiautomator dump` and screencaps whenever the UI **text** changes is
enough, so the capture never depends on tap timing; that is how `04-options.png` was taken.
## Store listing copy ## Store listing copy
@@ -212,5 +218,5 @@ in `app/build.gradle.kts`, and the second-run speed-up is the persistent hash in
- **Category:** Tools (alternative: Photography — *Tools* matches the one-job utility framing - **Category:** Tools (alternative: Photography — *Tools* matches the one-job utility framing
better, and Photography is more crowded). better, and Photography is more crowded).
- **Tags:** duplicate photos, photo cleanup, storage. - **Tags:** duplicate photos, photo cleanup, storage.
- **Contact details:** the same e-mail as in [PRIVACY.md](../PRIVACY.md) — replace the - **Contact details:** `softwareapp.hb@gmail.com` — the same address as in
`support@example.com` placeholder there first. [PRIVACY.md](../PRIVACY.md), so the policy can be hosted as-is.