7985d33869
The R8 defect fixed in the previous change was invisible to every check that existed: it passed the whole unit suite, passed lint, and worked in the debug APK, and the release APK was the only thing that disagreed. Nothing in the process had ever run it. The checklist now has a section for that, placed with the artefact work rather than at the end, because the order in which the document is meant to be worked is build, run it, then declare things to Google. It carries the install commands (the release build is a separate package, so it can sit beside the debug one and be compared directly), the MIUI trap that a bare INSTALL_FAILED_USER_RESTRICTED means its dialog was answered late rather than that the APK is bad, the paths most worth walking (thumbnails, an operation long enough to show the notification, the two SQLite databases, each viewer, the FileProvider), a dumpsys check of what the installed build actually holds rather than what the APK declares, and the one instruction that matters when something differs - assume R8 removed something instead of assuming the feature is broken, because a class named only from a resource or reached only by reflection looks unused to a shrinker. A last item points the walk at the demo library from the screenshot section, so it is repeatable and a reviewer's own files stay out of it. Renumbering meant fixing four references: two inside the checklist, and one each in STORE_LISTING (the screenshot section is now 7) and MONETISATION (pricing is now 8). A checklist that points at the wrong step is worse than one that points nowhere, so they were traced rather than assumed. Section 3 also gets a fresh measurement, since its own rule is that a stale hash is worse than no hash because it reads as though it had been checked. The record it replaces was from 2026-10-15 and predated the library views, the duplicate finder and video thumbnails: the AAB is now 5 181 628 bytes (sha256 f053d90f...) and the APK 2 294 021 bytes (sha256 afe03dea...), both versionCode 1 / versionName 1.0.0 / targetSdk 36, with the permission set unchanged and still without INTERNET or any READ_MEDIA_*. Two smaller repairs came out of the same pass. A link added in the previous commit pointed at EDGE_CASE_TESTING.md from the repository root, where it does not resolve - the changelog's other links all carry the docs/ prefix - and that was found by writing a link checker and running it over every relative link in the markdown: 37 of them, none now broken. And the re-measure warning had ended up stated twice, once per measurement record, so it now appears once, after the newest numbers and immediately before the upload steps. Documentation only; no code changed, so the suite stands at 324 JVM tests and lint clean. The walk itself was done on the phone in the previous change - what is new is that the process now asks for it, which is the part that would have caught the defect in the first place.
229 lines
14 KiB
Markdown
229 lines
14 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 or with your own accounts.
|
||
|
||
## 1. Identity and signing
|
||
|
||
- [x] **Application id** chosen: `de.softwareapp_hb.filemanager`
|
||
(`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] **[code] Upload keystore generated** — `filemanager-upload.jks` in the project root
|
||
(git-ignored, `alias upload`, 4096-bit RSA, 10 000 days, `CN=Hadrian Burkhardt,
|
||
L=Potsdam, C=DE`). Certificate SHA-256 fingerprint, to confirm the right key is
|
||
registered when Play App Signing is enabled:
|
||
`49:A9:FC:13:F5:13:24:48:AE:74:5A:AE:7E:05:D9:2F:67:55:1F:EE:71:A5:64:A1:10:13:57:4D:0E:AA:0E:4E`
|
||
- [x] **[code] `keystore.properties` written** (git-ignored, mode `600`). The Gradle build
|
||
picks it up automatically and signs release builds; without it the release APK stays
|
||
unsigned.
|
||
- [ ] **Store the keystore and its password in a password manager and back it up.** Losing
|
||
the upload key means you cannot ship updates.
|
||
- [ ] Enable **Play App Signing** in the Play Console with the first upload. Google then
|
||
holds the app signing key and this one only signs uploads:
|
||
```bash
|
||
keytool -exportcert -alias upload -keystore filemanager-upload.jks -rfc > upload_cert.pem
|
||
```
|
||
|
||
## 2. Versioning
|
||
|
||
- [x] `versionName` **[code]** — `1.0.0` in `app/build.gradle.kts`.
|
||
- [x] `versionCode` **[code]** — `1`. Play orders uploads by it, so 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 checks
|
||
./gradlew :app:testDebugUnitTest # JVM unit tests
|
||
./gradlew :app:lintDebug # Android Lint
|
||
```
|
||
|
||
- [x] **[code] re-verified 2026-10-15.** `bundleRelease` and `assembleRelease` both succeed
|
||
with R8 minification and resource shrinking on.
|
||
- `app-release.aab` is **4 838 235 bytes**
|
||
(`sha256 e1d23130b307345b1147e5cf54413d42ed53a1693e0682464cd1b64552e0ea78`).
|
||
- `app-release.apk` is **2 155 455 bytes**
|
||
(`sha256 37fb46cb3ef4908b73a9147442ff3f93905a893b8ddd27abb9e02610139a8cee`),
|
||
signed by the upload key above.
|
||
- Both report `versionCode=1 versionName=1.0.0 targetSdk=36` and exactly the
|
||
permission set in step 5 — **no `INTERNET`**, so "nothing leaves the device" holds
|
||
in the shipped artefact and not just in the source manifest.
|
||
- The figures barely moved again, but the permission set did: the four `READ_MEDIA_*`
|
||
declarations are gone, removed once the Android 15 pass showed the app never
|
||
requested them and never read through `MediaStore` (see below), together with the
|
||
archive fix for `.tar.bz2`/`.tar.xz`. Small source changes barely register in a
|
||
bundle dominated by libraries, hence 4 838 235 rather than a visible drop; the first
|
||
measurement was 4 682 814 / 2 081 955, before the LZMA library behind `.xz` and 7z.
|
||
The permission set has only ever shrunk: the share target, the file picker, the
|
||
trash index and the archive work all arrived without a single new permission, which
|
||
is the point of having designed them that way.
|
||
- [x] **[code] re-verified 2026-10-17.** `bundleRelease` and `assembleRelease` both succeed with
|
||
R8 and resource shrinking on.
|
||
- `app-release.aab` is **5 181 628 bytes**
|
||
(`sha256 f053d90f2f7c22727da5607af6e0f8b9a0322849ec3488356cd3d049e3d2d08c`).
|
||
- `app-release.apk` is **2 294 021 bytes**
|
||
(`sha256 afe03deab97dae672ad4c2d887be1b980d8ffe45544a5b97bc6c9cb23b80e8c7`), signed by
|
||
the upload key above.
|
||
- Both report `versionCode=1 versionName=1.0.0 targetSdk=36` and the permission set in
|
||
step 5 — still **no `INTERNET`**, and now also no `READ_MEDIA_*`.
|
||
- What moved the numbers since 2026-10-15: the library views and their index (schema v3),
|
||
the duplicate finder, and video thumbnails — the last of which added
|
||
`io.coil-kt.coil3:coil-video`. More importantly, this is the first build of which the
|
||
**release APK itself was run on a device** (section 4), which is how a defect that had
|
||
passed every other check was found.
|
||
- ⚠️ **Re-measure after any further change.** These numbers are a record of a verified
|
||
artefact, not a constant — a stale hash here is worse than no hash, because it looks
|
||
checked.
|
||
- [ ] Upload the **`.aab`** (not the APK) to the Play Console.
|
||
⚠️ **Expect a native-debug-symbols warning and ignore it.** Play finds
|
||
`base/lib/<abi>/libandroidx.graphics.path.so`, which arrives transitively through
|
||
`androidx.compose.ui:ui-graphics` → `androidx.graphics:graphics-path`. It is a
|
||
recommendation, not an error: it does not block upload, review or rollout. This
|
||
project has **no** `ndk`/`externalNativeBuild`/`jniLibs` configuration and no native
|
||
sources of its own.
|
||
- [ ] Keep `app/build/outputs/mapping/release/mapping.txt` for every release so Play can
|
||
de-obfuscate crash reports.
|
||
|
||
## 4. Run the release build, on a device, before shipping
|
||
|
||
**Nothing here is automated, and this is the step that catches what the debug build hides.** A
|
||
minified, shrunk and obfuscated build is a *different program* from the one the tests and the debug
|
||
APK exercise: R8 removes what it cannot see being used, and a feature that works everywhere else can
|
||
be inert in the artefact you are about to publish.
|
||
|
||
That is not hypothetical. Video thumbnails were exactly this (2026-10-17, recorded in
|
||
[EDGE_CASE_TESTING](EDGE_CASE_TESTING.md)): `coil-video` registers its frame decoder through a
|
||
`META-INF/services` entry, R8 removed both the entry and the class it named, and the debug APK drew
|
||
frames while the release APK would have shown an icon to every user. No unit test, no lint run and
|
||
no debug build could have caught it — every check above passed.
|
||
|
||
- [ ] Install the **signed release** APK. It is a different package from the debug build, so both
|
||
can be installed side by side, which makes the comparison trivial:
|
||
```bash
|
||
adb install -r app/build/outputs/apk/release/app-release.apk
|
||
adb shell appops set de.softwareapp_hb.filemanager MANAGE_EXTERNAL_STORAGE allow
|
||
adb shell pm grant de.softwareapp_hb.filemanager android.permission.READ_EXTERNAL_STORAGE
|
||
```
|
||
⚠️ On MIUI the install waits for its own confirmation dialog, which times out on its own —
|
||
a bare `INSTALL_FAILED_USER_RESTRICTED: Install canceled by user` means the dialog was
|
||
answered late, not that the APK is bad.
|
||
- [ ] Use the **demo library** from section 7 for the walk rather than whatever happens to be on
|
||
the phone: it is generated, it holds images, a real ZIP and — with one `ffmpeg` command, also
|
||
in section 7 — real video, and it keeps a reviewer's own files out of the way.
|
||
- [ ] Walk the paths most likely to depend on something R8 cannot see:
|
||
- **Thumbnails — images *and* video**, since a video frame arrives through a registered
|
||
decoder.
|
||
- **One operation long enough to show the progress notification**, and the foreground
|
||
service behind it.
|
||
- **Search everywhere** and **Trash**, which both open an SQLite database.
|
||
- **One of each built-in viewer**: image, text, PDF, media, archive, APK.
|
||
- **Receiving a share**, and **"open with"**, which run through the `FileProvider`.
|
||
- [ ] Confirm what the *installed* build actually holds, not only what the APK declares:
|
||
```bash
|
||
adb shell dumpsys package de.softwareapp_hb.filemanager | grep -A20 'requested permissions'
|
||
```
|
||
- [ ] When something behaves differently from the debug build, **assume R8 removed something**
|
||
rather than that the feature is broken. The usual culprits are a class named only from a
|
||
resource (`META-INF/services`) and one reached only by reflection: register it in code, or add
|
||
a keep rule, and say why in a comment so it is not removed again.
|
||
- [ ] Re-measure the figures in section 3 afterwards — a keep rule or a new dependency moves them.
|
||
|
||
## 5. Sensitive permissions — the Permissions Declaration Form
|
||
|
||
This is the step that can block a file manager, and the one to get right.
|
||
|
||
- [ ] In the Play Console open **App content → Sensitive app permissions** (older UI:
|
||
*Permissions Declaration Form*) and declare **All files access**
|
||
(`MANAGE_EXTERNAL_STORAGE`).
|
||
- [ ] Select the **file manager** use case. Google's policy — *Use of All files access
|
||
(MANAGE_EXTERNAL_STORAGE) permission* — names file managers explicitly:
|
||
*"intended and permitted use includes file managers, backup and restore apps,
|
||
anti-virus apps, and document management apps."*
|
||
- [ ] Make sure the **store description states the core purpose** (browsing, organising and
|
||
managing files on the device). The policy requires the core functionality to be
|
||
"prominently documented and promoted in the app's description" — the listing copy in
|
||
`docs/STORE_LISTING.md` (M10) is written to satisfy that.
|
||
- [ ] **Do not** add `QUERY_ALL_PACKAGES`, `REQUEST_INSTALL_PACKAGES` or `INTERNET` without
|
||
declaring them. The app currently requests none of them.
|
||
- [ ] Provide the **privacy policy URL** ([PRIVACY.md](../PRIVACY.md) — it must be a public
|
||
URL, not a file in the repo).
|
||
- [ ] Keep the permission set honest: declaring a permission the app does not use is itself
|
||
a policy violation.
|
||
|
||
**Verify the shipped permission set** after every release build:
|
||
|
||
```bash
|
||
$ANDROID_HOME/build-tools/36.0.0/aapt2 dump permissions \
|
||
app/build/outputs/apk/release/app-release.apk
|
||
```
|
||
|
||
Expected, and nothing else:
|
||
|
||
```
|
||
MANAGE_EXTERNAL_STORAGE
|
||
READ_EXTERNAL_STORAGE (maxSdkVersion=32)
|
||
WRITE_EXTERNAL_STORAGE (maxSdkVersion=29)
|
||
FOREGROUND_SERVICE
|
||
FOREGROUND_SERVICE_DATA_SYNC
|
||
POST_NOTIFICATIONS
|
||
<applicationId>.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION (added by AndroidX)
|
||
```
|
||
|
||
No `READ_MEDIA_*` appears, and that is deliberate. Browsing and every read go through the
|
||
filesystem (Coil loads thumbnails and the viewers from a real path); the only `MediaStore`
|
||
use is the best-effort delete request, which "All files access" already covers. The app
|
||
never requested the media permissions either, so declaring them would mean declaring
|
||
permissions that are never used — which is exactly what section 5's "keep the permission
|
||
set honest" rule forbids.
|
||
|
||
## 6. Data safety, content rating, audience
|
||
|
||
- [ ] **Data safety** form: the app collects **nothing** and shares **nothing**. Answer "No"
|
||
to collection, "No" to sharing, and state that data is not transmitted off the device.
|
||
- [ ] Confirm the answers match [PRIVACY.md](../PRIVACY.md).
|
||
- [ ] The app now keeps a small local SQLite index of staged deletes (the trash), so that a
|
||
deletion can be undone after a restart. It never leaves the device, so **"collects
|
||
nothing" is still the honest answer** — but if a form asks whether the app *stores*
|
||
anything, the truthful answer is "on the device only, and it is the user's own file
|
||
paths". [PRIVACY.md](../PRIVACY.md) should say so explicitly.
|
||
- [ ] Content rating questionnaire: utility, no user-generated content, no ads.
|
||
- [ ] Target audience: not directed at children.
|
||
|
||
## 7. Store listing
|
||
|
||
Full copy and the generated graphics land in **M10** (`docs/STORE_LISTING.md`). Console
|
||
fields needed: app name, short description, full description, app icon 512×512, feature
|
||
graphic 1024×500, and the screenshots.
|
||
|
||
- [ ] Upload the screenshots from `store-assets/screenshots/`: **phone** 1080×2160 (5),
|
||
**7-inch tablet** 1200×1920 (4), **10-inch tablet** 2560×1600 (4). Play wants 4+ per
|
||
large-screen form factor. All three sets clear its rules — 24-bit PNG with no alpha,
|
||
shorter side ≥ 320, longer side ≤ 3840, and longer side ≤ 2× the shorter side.
|
||
`tools/make-store-screenshots.sh` enforces the aspect ratio and rejects a capture
|
||
whose app area is blank.
|
||
- [ ] Expect the 7-inch set to be single-pane and the 10-inch set two-pane. That is the
|
||
adaptive layout working, not an inconsistency — see the CHANGELOG.
|
||
- [ ] The committed sets show browsing, a folder list, an image preview and storage usage.
|
||
**None of them show search, undo, the trash or receiving a share** — four features the
|
||
app now has. The existing shots stay accurate (no layout changed), so this is an
|
||
opportunity rather than a defect: two or three more captures would let the listing
|
||
advertise them. See `tools/make-store-screenshots.sh`.
|
||
|
||
## 8. Release tracks and pricing
|
||
|
||
- [ ] Create the app, complete *App content*, then roll out
|
||
**internal → closed → production**, checking the pre-launch report on the way.
|
||
- [ ] ⚠️ **Set the price before the first upload.** Play's own wording: *"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."* So:
|
||
1. Create a **payments profile** (identity, tax, bank details).
|
||
2. *Products › App pricing*: base price **€4.49**.
|
||
3. Launch price **€2.29** for the first **14 days**, then diarise changing it back.
|
||
4. Do every step above **before uploading**.
|
||
- [x] **[code] no Billing client and no `BILLING` permission** — the price lives on the
|
||
listing, not in the build, so the APK contains no purchase code at all. Verified
|
||
2026-10-14 against the release APK. Full rationale and the rejected alternatives:
|
||
[MONETISATION](MONETISATION.md).
|