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:
@@ -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
|
||||
first release**, so treat it as locked. `namespace` and the Kotlin package of
|
||||
the same name are aligned with it; debug builds append `.debug`.
|
||||
- [ ] Generate an **upload keystore** (keep it safe; if you lose it you cannot
|
||||
publish updates):
|
||||
```bash
|
||||
keytool -genkeypair -v -keystore upload-keystore.jks \
|
||||
-alias upload -keyalg RSA -keysize 4096 -validity 10000
|
||||
```
|
||||
- [ ] 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.
|
||||
- [x] Generate an **upload keystore** (keep it safe; if you lose it you cannot
|
||||
publish updates) — **[code]** done: `fotodup-upload.jks` in the project root
|
||||
(git-ignored, `alias upload`), `CN=Hadrian Burkhardt, L=Potsdam, C=DE`.
|
||||
- [x] Copy `keystore.properties.example` to `keystore.properties` and fill it in.
|
||||
**[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.
|
||||
- [ ] 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.
|
||||
|
||||
## 2. Versioning
|
||||
|
||||
- [ ] Set `versionName` (user-visible, e.g. `1.0.0`) **[code]** in
|
||||
`app/build.gradle.kts`.
|
||||
- [ ] Increment `versionCode` for **every** upload.
|
||||
- [x] Set `versionName` (user-visible) **[code]** — `1.0.1` in `app/build.gradle.kts`.
|
||||
- [x] `versionCode` **[code]** — currently **2**. It is the counter Play orders uploads by, so the
|
||||
first upload will be 2 and every later upload must be higher.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
- [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.
|
||||
- [ ] 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*).
|
||||
@@ -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
|
||||
same vectors as the adaptive icon in `res/drawable/ic_launcher_*`.
|
||||
- [x] **Feature graphic** 1024×500 PNG **[code]** — same script.
|
||||
- [ ] At least 2 phone screenshots (up to 8). Three are captured (empty state, scanning,
|
||||
results), but **re-shoot them after granting photo access on a fresh install**: the
|
||||
existing ones still show the "Photo access needed" banner, and the results capture shows
|
||||
the developer's own photo library. Capture the results screen over the generated demo
|
||||
library instead (`python3 tools/make-demo-photos.py`, then push and index it) — see the
|
||||
caveats in STORE_LISTING.md.
|
||||
- [x] **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
|
||||
(`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
|
||||
demo library (`tools/make-demo-photos.py`), so neither the "Photo access needed" banner nor
|
||||
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
|
||||
1080×2400 phone capture has to be cropped to 1080×2160;
|
||||
`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
|
||||
|
||||
- [ ] 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.
|
||||
⚠️ **Before hosting:** replace the `support@example.com` placeholder in the
|
||||
*Contact* section with a real address — a policy whose contact is a placeholder is a
|
||||
rejection risk.
|
||||
Play Console. **This is mandatory** because the app accesses photos. The contact address
|
||||
in it is final (`softwareapp.hb@gmail.com`), so the file publishes as-is.
|
||||
This is the one item here that cannot be finished from the working copy, because the repo
|
||||
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
|
||||
> 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.
|
||||
- [ ] Complete the **content rating** questionnaire (no objectionable content →
|
||||
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).
|
||||
- [ ] 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.
|
||||
|
||||
## 6. Testing before production
|
||||
@@ -164,6 +195,11 @@ action except the items marked **[code]**.
|
||||
|
||||
## 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
|
||||
for each release.
|
||||
|
||||
|
||||
+4
-4
@@ -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 |
|
||||
| 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). 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)
|
||||
|
||||
@@ -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) |
|
||||
| **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) |
|
||||
| **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 |
|
||||
|
||||
## 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
|
||||
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 only remainder is the re-shoot after a real
|
||||
permission grant (see blockers).
|
||||
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.
|
||||
4. **Colour signature** — the flat-colour false-positive fix.
|
||||
5. **M9 — monetisation**, once the product decision is made.
|
||||
|
||||
|
||||
+16
-10
@@ -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
|
||||
```
|
||||
|
||||
Two more screens are worth adding to the set. Each needs a tap, and MIUI only accepts taps from a
|
||||
human, so they are captured by hand — 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, and it catches the menu, the staging and a system dialog in one pass:
|
||||
This was run on the test device as soon as the four captures were taken: MediaStore went from
|
||||
**5,386 to 5,364** image rows and the 22 demo rows are gone. The matching rows in the app's own
|
||||
hash index are dropped by `PhotoIndexStore.pruneTo` on the next scan — the index is keyed by
|
||||
MediaStore id, so there is nothing else to reset.
|
||||
|
||||
- the staged state (*“All duplicates are ready to go”*) after *Remove all selected*. Nothing is
|
||||
deleted at that point and no dialog appears yet — the rows simply leave the list.
|
||||
- Android's **confirmation dialog** for the whole batch, which *Apply* launches. Dismissing it is
|
||||
non-destructive: the staging is kept, and *Apply* can be tapped again.
|
||||
Two further screens were weighed up and deliberately left out of the set: the staged state (*“All
|
||||
duplicates are ready to go”*) after *Remove all selected*, and Android's confirmation dialog for the
|
||||
batch that *Apply* launches. Staging every selected row also clears the demo group out of the list,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
better, and Photography is more crowded).
|
||||
- **Tags:** duplicate photos, photo cleanup, storage.
|
||||
- **Contact details:** the same e-mail as in [PRIVACY.md](../PRIVACY.md) — replace the
|
||||
`support@example.com` placeholder there first.
|
||||
- **Contact details:** `softwareapp.hb@gmail.com` — the same address as in
|
||||
[PRIVACY.md](../PRIVACY.md), so the policy can be hosted as-is.
|
||||
|
||||
Reference in New Issue
Block a user