6315af1f92
The field takes 250 characters, not the 1 689 the draft assumed, so the answer is now 243 characters - measured, not estimated, and deliberately pure ASCII, because one curly quote or dash pasted from a doc would silently shift the count: The app needs READ_MEDIA_IMAGES to read every photo, because duplicate detection compares each photo with every other. A picker exposes only selected files, and a photo's duplicates are exactly the files it withholds. All hashing is on-device. What survived the cut is the sentence that carries the argument: the reason the permission is needed is the same reason the picker cannot work - duplicates are, by definition, the files nobody selected. What went is the statistical-fit paragraph and the delete path, neither of which a reviewer needs in order to judge *this* question, and both of which are still in the longer drafts. Also here: a 228-character alternate that opens with the comparison rather than the permission, and the two long versions re-labelled as fallbacks for anywhere not capped at 250 (Play sometimes follows up by email). The evidence table stays unchanged - it is the defence if the answer is questioned, not part of what gets pasted.
550 lines
37 KiB
Markdown
550 lines
37 KiB
Markdown
# Google Play release checklist
|
||
|
||
Work top to bottom. Items marked **[code]** are already handled in this repository;
|
||
the rest are things you must do in the Play Console / with your own accounts.
|
||
|
||
## 1. Identity and signing
|
||
|
||
- [x] Final **application id** chosen: `de.softwareapp_hb.fotodup.finder`
|
||
(`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`.
|
||
- [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:
|
||
```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
|
||
|
||
- [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
|
||
|
||
```bash
|
||
./gradlew :app:bundleRelease # -> app/build/outputs/bundle/release/app-release.aab
|
||
./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.
|
||
⚠️ **Expect a native-debug-symbols warning here, and ignore it.** Play scans the bundle, finds
|
||
`base/lib/<abi>/libandroidx.graphics.path.so`, and reports *"This App Bundle contains native
|
||
code, and you've not uploaded debug symbols"*. It is a recommendation, not an error: it does
|
||
not block the upload, review or rollout.
|
||
- That one library (4 ABIs, ~37 KB total) is **not** this project's code — it arrives
|
||
transitively through `androidx.compose.ui:ui-graphics` →
|
||
`androidx.graphics:graphics-path:1.0.1`. The project has no `ndk` / `externalNativeBuild` /
|
||
`jniLibs` config and no native sources at all.
|
||
- There is nothing to upload: the published `graphics-path-1.0.1.aar` ships **stripped** libs
|
||
(`symtab=0`, no `.debug_*` sections, no symbol entries anywhere in the AAR), and the
|
||
commonly-suggested fix does not apply — with `ndk.debugSymbolLevel = "FULL"` the build
|
||
succeeds but produces no `app/build/outputs/native-debug-symbols/` output and adds nothing
|
||
to the bundle, because AGP only packages symbols for native code it builds itself.
|
||
- Excluding the lib (`packaging { jniLibs { excludes += "**/libandroidx.graphics.path.so" } }`)
|
||
would silence the warning but risks `UnsatisfiedLinkError` inside Compose's path handling,
|
||
for a 37 KB saving. Not worth it.
|
||
- What it actually costs: symbolication of frames *inside that Compose helper*. Kotlin/Java
|
||
crashes are unaffected — the R8 mapping rides inside the bundle
|
||
(`BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map`), so Play deobfuscates
|
||
them automatically.
|
||
- [ ] 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*).
|
||
|
||
## 4. Store listing
|
||
|
||
Ready-to-paste copy and the generated graphics live in
|
||
[docs/STORE_LISTING.md](STORE_LISTING.md) (milestone M10). Everything below is a Console
|
||
action except the items marked **[code]**.
|
||
|
||
- [ ] ⚠️ **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
|
||
STORE_LISTING.md (69 and 65 chars). **Neither says "free"**: the app is paid, so the claim would
|
||
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
|
||
same vectors as the adaptive icon in `res/drawable/ic_launcher_*`.
|
||
- [x] **Feature graphic** 1024×500 PNG **[code]** — same script.
|
||
- [x] **Phone screenshots** (at least 2, up to 8) **[code]** — four are captured and normalised to
|
||
1080×2160 in `store-assets/screenshots/phone/` (committed): 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).
|
||
- [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/`
|
||
(committed) 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
|
||
*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
|
||
taxonomy contains no duplicate/cleanup tag at all, so do not pad the field with
|
||
*Photo editor* just to fill it; the reasoning is in
|
||
[STORE_LISTING](STORE_LISTING.md) › Category and tags.
|
||
|
||
## 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. The contact address
|
||
in it is final (`softwareapp.hb@gmail.com`), so the file publishes as-is.
|
||
**Hosting half done (2026-10-14):** the working copy is published to the Gitea instance as
|
||
[`fishfacegit/foto-duplicate-finder`](https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder)
|
||
(`origin` = `ssh://git@gitea-container.incus:2222/fishfacegit/foto-duplicate-finder.git`), so
|
||
the policy now has a public, login-free URL:
|
||
- **rendered HTML (use this one in the Console):**
|
||
`https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/src/branch/main/PRIVACY.md`
|
||
- raw `text/plain` fallback:
|
||
`https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/raw/branch/main/PRIVACY.md`
|
||
|
||
What is left is the Console paste: open the URL in a private window to prove it needs no
|
||
login, then put it in **App content › Privacy policy**. A dedicated HTML page (the landing
|
||
page would do) reads better than a file view inside the repo; 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
|
||
> copy yourself and link it from the listing.
|
||
- [ ] Complete the **Data safety** form:
|
||
- *Does your app collect or share any required user data types?* → **No**.
|
||
- Data types accessed: **Photos** — processed on-device only, not collected.
|
||
- *Is all user data encrypted in transit?* → N/A (no transmission).
|
||
- *Do you provide a way for users to request data deletion?* → Uninstalling
|
||
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
|
||
badging` on the release APK reports exactly the table below (plus
|
||
`…DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`) — and **no `INTERNET`**. Re-check from the
|
||
*merged* release manifest rather than the source one after any dependency change, so nothing
|
||
a library quietly adds slips past:
|
||
|
||
| Declared permission | Why | From |
|
||
|---|---|---|
|
||
| `READ_MEDIA_IMAGES` | read the photo library | app |
|
||
| `READ_MEDIA_VISUAL_USER_SELECTED` | Android 14 partial access | app |
|
||
| `READ_EXTERNAL_STORAGE` (`maxSdkVersion=32`) | same, on Android ≤ 12 | app |
|
||
| `WRITE_EXTERNAL_STORAGE` (`maxSdkVersion=29`) | legacy delete on Android 10 only | app |
|
||
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_DATA_SYNC` | keep a scan alive | app |
|
||
| `POST_NOTIFICATIONS` | scan-progress notification | app |
|
||
| `…DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` | framework-generated, not user-facing | AndroidX |
|
||
|
||
Deliberately **absent**: `INTERNET` and `MANAGE_EXTERNAL_STORAGE`. Both are
|
||
load-bearing for the "nothing leaves the device" claim, so re-check after any new
|
||
dependency:
|
||
|
||
```bash
|
||
./gradlew :app:processReleaseMainManifest
|
||
grep -A1 '<uses-permission' \
|
||
app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml \
|
||
| grep -oE 'android.permission.[A-Z_]+' | sort -u
|
||
```
|
||
|
||
(Match `<uses-permission` only. A bare `android.permission.` grep also picks up
|
||
`android.permission.DUMP`, which AndroidX puts on its profile-installer *receiver* —
|
||
a component restriction, not a permission the app requests.)
|
||
- [ ] Answer the **Photo and video permissions** declaration (App content › Photo and video
|
||
permissions). It lists `READ_MEDIA_IMAGES` as *undeclared* and asks one question per
|
||
permission — in a box with a hard **250-character** limit:
|
||
|
||
> **Read media images** — Describe why the Android photo picker or other system pickers do
|
||
> not fully support your app's core functionality, and why your app needs to use the
|
||
> `READ_MEDIA_IMAGES` permission.
|
||
|
||
Paste — **243** of the 250 characters, pure ASCII so the count cannot drift on paste:
|
||
|
||
> The app needs READ_MEDIA_IMAGES to read every photo, because duplicate detection compares
|
||
> each photo with every other. A picker exposes only selected files, and a photo's duplicates
|
||
> are exactly the files it withholds. All hashing is on-device.
|
||
|
||
One sentence carries the whole argument: the reason the permission is needed *is* the reason
|
||
the picker cannot work. A picker hands over the files the user selected, and duplicates are
|
||
by definition the files nobody selected — the copies of a picked photo live in the part of
|
||
the library the picker never exposes. The closing sentence is there because a permission
|
||
declaration is also a privacy statement, and 25 characters is a cheap way to say it. If you
|
||
would rather open with the comparison than with the permission, this fits too (**228**):
|
||
|
||
> Finding duplicates requires comparing every photo with every other, so the app must read
|
||
> the whole library via READ_MEDIA_IMAGES. A picker exposes only selected files, and a
|
||
> photo's duplicates are exactly the files it withholds.
|
||
|
||
The longer versions below are kept for anywhere that is *not* capped at 250 — Play sometimes
|
||
follows up by email, and both read well wherever more detail is wanted. **1 689 characters**:
|
||
|
||
> Foto Dup Finder's core functionality is finding duplicate and visually similar photos
|
||
> across the user's whole photo library, and letting the user delete the copies. Reading the
|
||
> photo library is that entire operation.
|
||
>
|
||
> The Android photo picker cannot support it. The picker only grants access to the items the
|
||
> user selects in that session, and the duplicates are by definition outside that selection:
|
||
> the copies of a picked photo are other files, in other folders, that the picker never hands
|
||
> over. Comparing only the picked items tells the user nothing they did not already know;
|
||
> finding the groups requires comparing every photo in the library with every other photo.
|
||
>
|
||
> The app also needs the whole library to calibrate itself. It estimates the distance
|
||
> distribution of unrelated photos from the library's own pairwise perceptual-hash distances
|
||
> and sets the widest similarity threshold whose expected number of accidental matches stays
|
||
> in budget. Below a few hundred pairs that fit is impossible and the app falls back to a
|
||
> default; over a hand-picked subset it would be meaningless, because a selection is not a
|
||
> sample of the library — it is the part the duplicates are not in.
|
||
>
|
||
> Nothing is uploaded: the app declares no INTERNET permission and all hashing happens on the
|
||
> device. Photos are only read. Any deletion requires an explicit selection by the user and
|
||
> always goes through Android's own confirmation dialog (`MediaStore.createDeleteRequest` /
|
||
> `createTrashRequest`) — never silently — and the app never requests
|
||
> `MANAGE_EXTERNAL_STORAGE`. Partial access (`READ_MEDIA_VISUAL_USER_SELECTED`) is declared
|
||
> and supported as a reduced mode for users who prefer to hand over fewer photos.
|
||
|
||
…and **682 characters**, for a box with less room than that:
|
||
|
||
> Foto Dup Finder's only function is finding duplicate and similar photos across the user's
|
||
> whole photo library and letting them delete the copies: reading the library is the feature.
|
||
> The Android photo picker grants access only to the items a user selects, while the
|
||
> duplicates of a picked photo are precisely the files the picker never hands over — a
|
||
> selection cannot answer "what is duplicated in my library". The app also calibrates its
|
||
> similarity threshold from the library's own pairwise distance distribution, which needs the
|
||
> whole library to mean anything. Nothing is uploaded (the app has no INTERNET permission)
|
||
> and deletion always goes through Android's confirmation dialog.
|
||
|
||
Nothing in any of them is aspirational — every claim is a place in the code, so it can be
|
||
defended if Play comes back with questions:
|
||
|
||
| The answer claims … | … and this is where that is true |
|
||
|---|---|
|
||
| the app reads the whole library | `MediaStorePhotoSource.loadPhotos()` queries the image collection with a `null` selection — metadata only, pixels later and lazily |
|
||
| duplicate detection needs every pair | `DuplicateGrouper` clusters by exact SHA-256 *and* perceptual fingerprints, union-find over the whole scan |
|
||
| the threshold is fitted per library | `AdaptiveThreshold.choose()` estimates the unrelated-photo distribution from the scan's own pairwise histogram and returns the widest threshold keeping the *expected* number of accidental matches in budget; under `MIN_SAMPLE` = 200 pairs it falls back to the policy ceiling |
|
||
| a selection cannot stand in for a library | that histogram is built from every pair in the scan — the phone's 5,385-photo library fitted to *pHash ≤ 10 bits* and said so on screen, while the 22-photo tablet library was too small to fit and used its ceiling (`(default)`) |
|
||
| a constant threshold cannot work | `AdaptiveThreshold` KDoc and README § scan pipeline: the widest pHash threshold with zero false pairs falls from **13 (120 images) to 5 (2 880)** as a library grows |
|
||
| nothing leaves the device | no `INTERNET` in the merged release manifest — the `aapt2` audit in the item above |
|
||
| deletion is never silent | `MediaDeleter` → `MediaStore.createDeleteRequest` / `createTrashRequest`; no `MANAGE_EXTERNAL_STORAGE` |
|
||
| partial access is real, not a gesture | `AppPermissions.MediaAccess.PARTIAL` / `hasPartialAccess()`, with a banner and a prompt to upgrade to full access |
|
||
|
||
Three notes for filling the form in. (1) The declaration asks about `READ_MEDIA_IMAGES` only:
|
||
`READ_MEDIA_VISUAL_USER_SELECTED` is the *narrow* half of the pair and needs no justification,
|
||
so do not volunteer it as though it were the broad one. (2) If that screen also asks for a
|
||
**video**, the recorded foreground-service demo does show a full 5,385-photo library scan and
|
||
the groups it found, which is the functionality this declaration is about — but it was filmed
|
||
to answer the foreground-service question, so it is a fallback rather than the intended
|
||
answer, and a take focused on this question (open → grant → scan → groups) may be asked for.
|
||
(3) If the declaration were refused, the app already runs in the partial mode it implements
|
||
(banner + subset scan), so nothing would need rewriting — what would be lost is the promise
|
||
("find the duplicates you forgot about"), which is why the answer above is the one to stand
|
||
behind.
|
||
|
||
|
||
- [ ] Declare the **foreground service type**. This is the release-blocking item for the
|
||
Android 14+ review. **[code]** The manifest already matches what you will declare:
|
||
|
||
| Where | Value |
|
||
|---|---|
|
||
| `AndroidManifest.xml` | `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />` |
|
||
| `AndroidManifest.xml` | `<service android:name=".service.ScanService" android:foregroundServiceType="dataSync" android:exported="false" />` |
|
||
| `ScanService.kt` | `ServiceCompat.startForeground(…, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)` |
|
||
| `ScanService.kt` | `onTimeout()` stops the scan at Android 15's 6-hour `dataSync` cap |
|
||
|
||
The declaration page asks for **four** things per type, not one. Go to
|
||
**App content › Foreground service permissions**:
|
||
|
||
1. **Description** of the app functionality using the type:
|
||
|
||
> The app finds duplicate photos on the device. The user starts a scan and may leave
|
||
> the app while it runs; the foreground service keeps that user-initiated scan alive
|
||
> and shows its progress. The scan reads the photo library and writes a small local
|
||
> index of file hashes. It performs no network access and transfers no data.
|
||
|
||
2. **User impact if the task is deferred or interrupted.** A scan is not deferrable work —
|
||
the user is watching for a result — so there is no WorkManager/JobScheduler fallback:
|
||
|
||
> If the scan were deferred, tapping "Start scan" would appear to do nothing and the
|
||
> user would have to keep the app open for the work to begin at all. If it were
|
||
> interrupted, the user would lose the progress of a whole-library comparison and
|
||
> have to start again. Only reads are involved, so an interruption cannot lose or
|
||
> corrupt data; the service stops as soon as the scan finishes or is cancelled, and
|
||
> it is held only for the duration of the scan.
|
||
|
||
3. **Use case**: **Local processing › Other.** The Console puts three options under *Local
|
||
processing*, and the other two do not describe this app. *Media transcoding* means
|
||
converting media into a different format — the app writes no media file at all, it only
|
||
reads photos to hash and compare them. *Importing, exporting* means moving files in or
|
||
out of the app — the app's only write is its own private index of file hashes, which no
|
||
other app ever sees. Play's wording for *Other* is *"Use for specifically user-initiated
|
||
work and not for regular system or server-initiated tasks"*, which is exactly a scan the
|
||
user taps *Start scan* for. This is not a cosmetic choice: an inaccurate use case is a
|
||
misdeclaration, and the description and the video have to back up whichever option is
|
||
picked — "reads the photo library and writes a small local index of file hashes" only
|
||
fits *Other*.
|
||
|
||
4. **A link to a video** demonstrating the feature — *"The video should demonstrate the
|
||
steps the user needs to take in your app in order to trigger the feature."*
|
||
|
||
**Recorded on the test device** (Android 12 / MIUI, `2a2ae895`) on 2026-10-14 with:
|
||
|
||
```bash
|
||
INSTALL=0 WARMUP=0 ./tools/record-fgs-demo.sh 2a2ae895
|
||
```
|
||
|
||
The output is `store-assets/fgs-demo/foreground-service-demo.mp4` (**committed**, so it is
|
||
already published; the checkpoint PNG per milestone beside it stays git-ignored). It was
|
||
checked frame by frame afterwards rather than assumed: the intro at 0 s, the scan starting
|
||
(*"Comparing file signatures… 91/102"*, 8 s), the shade held open for ~6 s with the live
|
||
notification (*"Finding duplicate photos / Looking for visually similar photos… /
|
||
CANCEL"*, 13–18 s) while the scan kept running behind it, and the finished results
|
||
(*17 duplicate groups · 54 of 5,385 photos*) at the end.
|
||
|
||
**The link to paste** is the file's page in Gitea — what the repository's own *view* of
|
||
the file opens, and what plays it:
|
||
|
||
```
|
||
https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/src/branch/main/store-assets/fgs-demo/foreground-service-demo.mp4
|
||
```
|
||
|
||
⚠️ **Not the `/raw/` URL**, even though that is the file itself. Gitea answers raw
|
||
responses with `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline';
|
||
sandbox` — a deliberate guard against raw HTML and SVG — and that blocks the media load of a
|
||
document that navigates straight to a video: the tab stays blank with no error, which is
|
||
exactly how this behaved in a browser. The same URL is fine as a *subresource*: the page
|
||
above embeds `<video controls src="…/raw/branch/main/…mp4">`, which is why previewing the
|
||
file plays it and opening `/raw/` does not. Use `/raw/` for `curl` and downloads, never as
|
||
the link in the form.
|
||
|
||
Verified about the **page** URL: **200** anonymously, containing that `<video controls>`
|
||
element. Verified about the **raw** URL, from outside this network and unauthenticated:
|
||
**200**, `Content-Type: video/mp4`, `Content-Length: 1847900`, `Content-Disposition:
|
||
inline`, bytes sha256-identical to the recording, and `Range: bytes=0-99` → **206** with
|
||
exactly those 100 bytes although `Accept-Ranges` is not advertised (which matters because
|
||
`screenrecord` writes the `moov` atom at the very end of the file). Two things to know
|
||
before pasting: the URL is pinned to `main`, so a re-shoot has to **overwrite the file in
|
||
place** rather than be added alongside it; and replacing the video at all means resubmitting
|
||
the declaration.
|
||
|
||
If a link with no Gitea chrome around the player is preferred later, there are two ways and
|
||
neither is needed for the declaration: an unlisted YouTube upload of the same file, or a
|
||
rule on the Gitea host's Caddy proxy dropping the header for this one path
|
||
(`@fgsdemo path …/store-assets/fgs-demo/*.mp4` plus `header_down @fgsdemo
|
||
-Content-Security-Policy`) — **scoped to `store-assets/` only**, since that header is what
|
||
makes raw HTML and SVG safe to serve. Neither was tried from here.
|
||
|
||
Both flags exist because this take found two traps worth knowing before a re-shoot:
|
||
|
||
- **`WARMUP=0` keeps the *cold* scan on camera.** A warm scan of this library takes
|
||
**5.4 s**, which is less than the notification shade takes to open — so the first take
|
||
filmed an empty shade, convincingly. The cold first scan (~20 s) is what gives the
|
||
shade time to appear while the notification is still live, and reinstalling the app
|
||
restores that window.
|
||
- **`INSTALL=0` skips the install**, because MIUI does not let `adb` install anything:
|
||
the command **hangs** waiting for two prompts on the device — Google Play Protect
|
||
(*"App zum Sicherheitsscan senden?"*) and then MIUI's own *Installieren* — and
|
||
auto-cancels as `INSTALL_FAILED_USER_RESTRICTED` if they go unanswered. The simplest
|
||
route is to install once by hand and then record with `INSTALL=0`.
|
||
|
||
A hand recording is just as valid and needs no tooling: every Android 11+ phone has a
|
||
screen recorder in the quick settings, and the whole video is six steps — the intro, tap
|
||
*Start scan* (the trigger the declaration asks to see), press Home, pull the shade down
|
||
and leave the notification sitting there while the scan runs, tap the app back for the
|
||
results, stop. Under two minutes and uncut: a reviewer has to be able to follow the
|
||
trigger and see the service still working once the app has left the screen.
|
||
⚠️ **MIUI blocks synthetic input until the matching developer option is on.** Every
|
||
`adb shell input tap` on this phone fails with *"Injecting to another application
|
||
requires INJECT_EVENTS permission"* — which is why the project's earlier captures were all
|
||
taken by hand. With **USB debugging** enabled (and the security-settings toggle that comes
|
||
with it) the block lifts, `tools/record-fgs-demo.sh` drives the phone end to end and
|
||
nothing is needed from a human. Toggling it resets the USB connection, though: the device
|
||
comes back on a **new** `/dev/bus/usb` node, which has to be made readable before `adb`
|
||
sees it again.
|
||
|
||
⚠️ **It cannot be recorded on a KVM-less emulator**, and the failure is the platform's,
|
||
not the app's: every scan start there ends in
|
||
`am_crash … ForegroundServiceDidNotStartInTimeException` → *Force finishing activity*,
|
||
with no `am_kill` or low-memory record, because software emulation is too slow to get
|
||
from `startForegroundService()` to `startForeground()` inside Android's budget. QEMU's
|
||
virtual instruction counter dilates the guest clock and made it work exactly once
|
||
(`-qemu -icount shift=1,sleep=off`, guest clock measured 2.30× slow) but not
|
||
reproducibly, so that is a lead rather than a recipe; `SETTLE_SECONDS` / `WARM_SECONDS`
|
||
scale this script's waits if it is ever retried.
|
||
|
||
The declaration is **per type**: if a new foreground-service type is ever added to
|
||
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
|
||
|
||
- [ ] Create an **internal testing** track and add yourself as a tester; install via
|
||
the Play link on a real device.
|
||
- [ ] Test on the oldest supported Android version you can (minSdk 26) and on the
|
||
newest you have (Android 16 / API 36).
|
||
- [ ] Test the permission flows:
|
||
- [ ] Deny access — app must not crash and must explain what is needed.
|
||
- [ ] Android 14+ **partial access** ("Select photos") — banner appears and the
|
||
app scans the subset.
|
||
- [ ] Grant full access — scan works.
|
||
- [ ] Test deletion:
|
||
- [ ] Move to trash (Android 11+).
|
||
- [ ] Permanent delete (Android 11+).
|
||
- [ ] Refuse the system dialog — nothing is deleted, the app says so.
|
||
- [x] Test with a library of several thousand photos to sanity-check scan time. **[code]** done on
|
||
the test device: the 5,363-photo library scans in **20.2 s cold / 11.0 s warm**, and the
|
||
screenshot run over 5,385 photos finished in **11.1 s** (6 unreadable files skipped).
|
||
- [ ] Verify no crashes in *Play Console > Quality > Android vitals* after 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
|
||
for each release.
|
||
|
||
Where:
|
||
|
||
- Production: **Test and release > Production**, then **Create new release**
|
||
- Testing: **Test and release > Testing > Internal / Closed / Open testing**
|
||
|
||
Rules (per Google's *Prepare and roll out a release* guide):
|
||
|
||
- Up to **500 Unicode characters per language**.
|
||
- Language tags go on their own line, wrapping the text — a block starts with a line
|
||
containing `<en-US>` and ends with a line containing `</en-US>`. Several
|
||
`<lang>` blocks can be entered together.
|
||
- Notes belong to a **release**, so every track has its own; "Copy from a previous
|
||
release" avoids retyping.
|
||
- Google explicitly forbids using release notes for **promotion or to solicit user
|
||
actions** — keep them factual about what changed.
|
||
- For app updates list only user-visible changes; the full developer history belongs
|
||
in [CHANGELOG.md](../CHANGELOG.md).
|
||
|
||
Ready-to-paste block for the first release:
|
||
|
||
<en-US>
|
||
Initial release.
|
||
|
||
• Finds exact and near-duplicate photos
|
||
• Suggests which copy to keep
|
||
• Moves photos to the trash or deletes them via the system dialog
|
||
• No ads, no account, works fully offline
|
||
</en-US>
|
||
|
||
Ready-to-paste block for **1.0.1** (the scan-robustness patch):
|
||
|
||
<en-US>
|
||
• Fixed a scan that could appear to stop on very large photo libraries
|
||
• The app now recovers gracefully when the photo library is temporarily unresponsive
|
||
</en-US>
|
||
|
||
- [ ] Upload the `.aab`, paste the notes above, and roll out to **internal** →
|
||
**closed** → **production** (staged rollout recommended).
|
||
- [ ] Monitor Android vitals and reviews; respond to feedback.
|
||
|
||
## 8. Post-launch maintenance
|
||
|
||
- [ ] Play requires new apps/updates to target recent API levels (API 36 from
|
||
August 2026 **[code]**). Re-check this requirement each year and bump
|
||
`targetSdk`.
|
||
- [ ] Watch for newer AGP; when it supports it, update `compileSdk` to 37 and bump
|
||
the Compose BOM and Coil together (see the version note in the README).
|
||
- [ ] 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.
|
||
|
||
## 9. Monetisation — decided: one-time paid app, no billing
|
||
|
||
**The decision is made: Foto Dup Finder is a one-time paid app** (rationale, the €2.99 base price,
|
||
the €1.49 launch sale and the irreversibility rule: [STORE_LISTING](STORE_LISTING.md) › App pricing).
|
||
The Console work is in step 4; **nothing in this repository changes**, because a price on the listing
|
||
adds no code, no permission and no Data Safety answer — which is exactly what a free app with a Pro
|
||
unlock could not claim.
|
||
|
||
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.
|