Files
foto-duplicate-finder/docs/PLAY_RELEASE_CHECKLIST.md
T
SoftwareApp 52cf2fc85a feat: record the FGS video on the real device, and fix what the take exposed
The declaration's video exists now: store-assets/fgs-demo/foreground-service-demo.mp4, recorded on
the test device (Android 12 / MIUI, 2a2ae895) with INSTALL=0 WARMUP=0, and then checked frame by
frame 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 at the end. Hosting it and pasting the link into the declaration is
what remains.

The script grew two flags, both because an earlier attempt got it wrong:

- WARMUP=0 skips the warm-up and keeps the *cold* scan on camera. A warm scan of this library
  takes 5.4 s - less than the notification shade takes to open - so the first phone take filmed an
  empty shade, convincingly. The warm-up only ever existed for the emulator's FGS deadline
  problem, which a real device does not have.
- INSTALL=0 records with the build already on the device, because MIUI does not allow adb
  installs: the command hangs on two on-device prompts (Google Play Protect, then MIUI's own
  Installieren) and auto-cancels as INSTALL_FAILED_USER_RESTRICTED if they go unanswered.

Also found by running this against a phone instead of an AVD:

- the trigger is chosen by mode - the intro's "Start scan" when the warm-up is skipped, the
  results screen's top-bar "Scan" when it is not - and the lookup result is checked rather than
  assumed,
- pacing is a variable now, tightened in the WARMUP=0 path, because the phone's cold scan is only
  ~20 s and the shade has to be open while it runs,
- the library check counts the demo folder rather than the whole device library: on a phone the
  rest of the library is the owner's own photos and is supposed to be there,
- the warm-up became a function and does not run at all under WARMUP=0, and a stray reference to
  its `attempt` variable (fatal under `set -u`) is gone.

Step 5 of the checklist now carries the recorded recipe, both flags with the reasons, and two
device facts that were invisible until a phone was attached: MIUI fails every synthetic tap with
INJECT_EVENTS until USB debugging is enabled, and toggling that resets the USB connection onto a
new /dev/bus/usb node. The emulator verdict is compressed to what it is - the platform kills the
app on every scan start there (am_crash ForegroundServiceDidNotStartInTimeException, no am_kill),
with QEMU's -icount clock dilation noted as the one thing that ever worked, and only once.
2026-10-14 13:16:24 +02:00

29 KiB
Raw Blame History

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

  • 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.
  • 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.
  • 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

  • Set versionName (user-visible) [code] — 1.0.1 in app/build.gradle.kts.
  • 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

./gradlew :app:bundleRelease      # -> app/build/outputs/bundle/release/app-release.aab
./gradlew :app:assembleRelease    # -> APK for local testing
  • [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 (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 › 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.
  • 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_*.
  • Feature graphic 1024×500 PNG [code] — same script.
  • Phone screenshots (at least 2, up to 8) [code] — four are captured and normalised to 1080×2160 in store-assets/screenshots/phone/ (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).
  • 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/ (git-ignored) 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.
  • 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 › 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 › Category and tags.

5. Policy and compliance

  • Host 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 (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.
  • 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 in the Console. The app requests READ_MEDIA_IMAGES (plus READ_MEDIA_VISUAL_USER_SELECTED for Android 14 partial access) and deletes only user-selected files through the system dialog. Paste:

    > Foto Dup Finder is a photo-cleanup tool. Reading the photo library **is** its core
    > functionality: it compares photos on the device to find exact and near duplicates
    > and lets the user remove the copies they select. Nothing is uploaded — the app
    > declares no network permission. Deletion always goes through the system consent
    > dialog (`MediaStore.createDeleteRequest`); the app never requests
    > `MANAGE_EXTERNAL_STORAGE`.
    
  • 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` (git-ignored, like the
       screenshots) with a checkpoint PNG per milestone beside it. 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. Host the file where a reviewer can open it without
       signing in — an unlisted YouTube link is the usual choice — and paste that link into the
       declaration. Replacing the video later means resubmitting it.
    
       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.
  • 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.

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 › 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 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 current.