f4ca5f22ae
The phone set was two days old and predated M16-M20: nothing in it showed the copy-to picker, the trash's window or the compress dialog. It is now eight shots (Play's maximum) taken from the signed release build over the demo library: home, folder list, photo grid, storage usage, sort sheet, Copy to..., the trash with its 24-hour line and Empty trash, and the compress dialog with its switch. The shots have to stay inside the demo library, so two things were deliberately not captured: the home screen (its library counts are the real phone's - the old AVD-shot file is kept) and the picker one level down (it would name real folders). The picker is shot at the volume list instead. Taking the picker shot exposed a real bug: it listed '.plainfiles-trash' among the destinations, because unlike every other listing it did not filter hidden entries. Hidden folders are now offered only when the listing shows hidden files, with a test for both halves. The tablet sets are unchanged. The tablet AVDs cannot boot on this machine (no KVM) and the phone-resized path needs more navigation work than this session had left; the checklist says so and records what to regenerate and where.
234 lines
14 KiB
Markdown
234 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 (8),
|
||
**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.
|
||
- [x] The phone set was re-shot on 2026-10-18 and now shows **browsing, a folder list, the photo
|
||
grid, storage usage, the sort sheet, the Copy to… picker, the trash (with its 24-hour window and
|
||
Empty trash) and the compress dialog** — the last three are what M16-M20 added. **Still not
|
||
shown**: search, undo, and receiving a share. The picker shot is taken at the volume list on
|
||
purpose: one level down it would publish the real phone's folder names, which the demo-library
|
||
rule above forbids.
|
||
- [ ] The tablet sets were **not** re-shot: the TV/tablet AVDs cannot boot on this machine (no KVM),
|
||
and the phone-resized path needs more navigation work. They stay accurate for the screens they
|
||
show (roots, folder list, image preview, storage usage) and should be regenerated on a machine
|
||
with KVM before the listing goes live.
|
||
|
||
## 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).
|