From 55101886cbb42c60c8bfd88d8330d7a462d97502 Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Tue, 13 Oct 2026 23:59:15 +0200 Subject: [PATCH] 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. --- PRIVACY.md | 3 +- docs/PLAY_RELEASE_CHECKLIST.md | 82 ++++++++++++++++++++++++---------- docs/ROADMAP.md | 8 ++-- docs/STORE_LISTING.md | 26 ++++++----- 4 files changed, 80 insertions(+), 39 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 8c9caac..a4acc84 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -63,5 +63,4 @@ new "last updated" date. ## Contact -Questions about this policy: **support@example.com** _(replace with your real -support address before publishing)_. +Questions about this policy: **softwareapp.hb@gmail.com**. diff --git a/docs/PLAY_RELEASE_CHECKLIST.md b/docs/PLAY_RELEASE_CHECKLIST.md index f180de7..71b97fe 100644 --- a/docs/PLAY_RELEASE_CHECKLIST.md +++ b/docs/PLAY_RELEASE_CHECKLIST.md @@ -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:/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://.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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8e25c86..ee955fa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 ``, 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 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. diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index 3bf1bec..b23c4dd 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -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.