Initial commit: Foto Dup Finder 1.0.1

An Android duplicate-photo finder. Pure-Kotlin core (SHA-256 + dHash hashing, exact and
perceptual union-find grouping, three sensitivity presets) with MediaStore access, a
persistent SQLite hash index and a tiered ScanEngine behind a Jetpack Compose UI.
No INTERNET permission, no ads, no analytics, no account.

Ships versionCode 2 / versionName 1.0.1, carrying the scan-stall hardening (BoundedCall
breaker) and verified against a 5,363-photo device library. 80 JVM unit tests, lint clean.

Release paperwork is in docs/ (Play checklist, store listing, edge-case testing), the
upload-ready Play graphics and screenshots in store-assets/, and the generators for those
assets in tools/.
This commit is contained in:
Hadrian Burkhardt
2026-10-13 23:13:39 +02:00
commit 4706e2669c
100 changed files with 8998 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
# 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`.
- [ ] Generate an **upload keystore** (keep it safe; if you lose it you cannot
publish updates):
```bash
keytool -genkeypair -v -keystore upload-keystore.jks \
-alias upload -keyalg RSA -keysize 4096 -validity 10000
```
- [ ] Copy `keystore.properties.example` to `keystore.properties` and fill it in.
**[code]** The Gradle build picks it up automatically and signs release
builds; without it the release APK stays unsigned.
- [ ] Enable **Play App Signing** in the Play Console and upload the certificate of
your *upload* key.
- [ ] Store the keystore + passwords in a password manager / secure vault. Back it up.
## 2. Versioning
- [ ] Set `versionName` (user-visible, e.g. `1.0.0`) **[code]** in
`app/build.gradle.kts`.
- [ ] Increment `versionCode` for **every** upload.
## 3. Build the release artefacts
```bash
./gradlew :app:bundleRelease # -> app/build/outputs/bundle/release/app-release.aab
./gradlew :app:assembleRelease # -> APK for local testing
```
- [ ] Upload the **`.aab`** (not the APK) to the Play Console.
- [ ] Keep `app/build/outputs/mapping/release/mapping.txt` for every release so you
can de-obfuscate crash reports (upload it under *Release > App bundle explorer*).
## 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 name (≤ 30 chars): "Foto Dup Finder".
- [ ] Short description (≤ 80 chars) — two options are drafted and length-checked in
STORE_LISTING.md.
- [ ] Full description (≤ 4000 chars) — drafted and length-checked in STORE_LISTING.md.
- [x] **App icon** 512×512 PNG **[code]** — `tools/make-store-assets.sh` renders it from the
same vectors as the adaptive icon in `res/drawable/ic_launcher_*`.
- [x] **Feature graphic** 1024×500 PNG **[code]** — same script.
- [ ] At least 2 phone screenshots (up to 8). Three are captured (empty state, scanning,
results), but **re-shoot them after granting photo access on a fresh install**: the
existing ones still show the "Photo access needed" banner — see the caveat in
STORE_LISTING.md.
⚠️ 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).
- [ ] Category: *Tools* (or *Photography*). Tags: photo cleanup, duplicates.
## 5. Policy and compliance
- [ ] Host [PRIVACY.md](../PRIVACY.md) at a public URL and paste that URL into the
Play Console. **This is mandatory** because the app accesses photos.
⚠️ **Before hosting:** replace the `support@example.com` placeholder in the
*Contact* section with a real address — a policy whose contact is a placeholder is a
rejection risk.
> **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.
- [ ] Confirm the **permission set** the Console will show. Read it from the *merged*
release manifest, not the source one, 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 |
In the Console go to **App content › Foreground service permissions**, select
**Data sync**, and paste:
> 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.
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+).
- [ ] Select the target audience (not designed for children).
- [ ] 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.
- [ ] Verify no crashes in *Play Console > Quality > Android vitals* after rollout.
## 7. Release notes ("What's new") and rollout
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. Optional monetisation
The app currently has no ads and no billing, which is a deliberate differentiator.
If you later add a paid tier, use **Play Billing** (e.g. a one-time "Pro"
purchase) and update:
- the Data safety form (purchase history),
- the privacy policy,
- the store description.
Avoid ad SDKs entirely — they would contradict the app's main selling point.