# 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//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 ' **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. Answer the *picker* half first and the *permission* half second — that is the order the question asks in, and the picker half is the one a reviewer is actually weighing. Paste (1 689 chars): > 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. If the box is smaller, this says the same in 682 chars: > 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 either version 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` | `` | | `AndroidManifest.xml` | `` | | `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 `