Files
file-manager/docs/PLAY_RELEASE_CHECKLIST.md
SoftwareApp ddcd68b4b7
verify / gate (push) Has been cancelled
verify / release (push) Has been cancelled
Make Copy to… and Cut to… the selection bar's actions
The selection bar offered Copy and Move, which only filled an in-app clipboard; the
destination picker then lived behind an overflow menu, and Paste in the top bar. That
made a copy a two-step detour through another folder - the thing M16's picker exists to
avoid.

- The bar's two verbs are now Copy to… and Cut to…, both opening the destination picker.
- The clipboard state, Paste here, and the overflow's duplicate Copy to… / Move to… are
  removed. The picker's cut title and confirm are Cut to… / Cut here.
- A copy still keeps the selection for a second destination; a cut clears it. Conflicts
  still ask before overwriting, naming the picked folder.
- Five clipboard tests are removed; the mark, restart and undo tests now use the picker.
  BrowserTopBar's search and overflow sections are extracted so its two new detekt
  findings are fixed rather than baselined.

Driven on the Xiaomi phone: Copy to… copied 120 x 16 MB through Internal storage ->
AADemo -> Target and reported Copied 121 items to Target; Cut to… moved file001.bin and
offered Undo; a name in the way raised Items already exist here; the top-bar overflow
has no Paste. The foreground-service video was re-recorded against the new bar at 16.1 s.

Verified: ./tools/verify.sh green, the code map current, 426 unit tests pass, and the
video decodes as H.264 1080x2400 with no audio.
2026-10-18 15:07:35 +02:00

298 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
**The declaration answer (490 / 500 characters, measured):**
```
Plain Files' core purpose is browsing and managing every file and folder on internal storage, SD cards and USB drives: documents, archives, APKs, hidden files and folders created by other apps. The Storage Access Framework grants one user-picked tree at a time and cannot enumerate volumes or present a whole-device hierarchy. MediaStore exposes indexed media collections, not arbitrary files or folder trees. SAF remains a fallback for a chosen folder, but cannot deliver the core purpose.
```
This is a **functional** argument, which is the only kind the form accepts — Google's own wording
says improving performance is not an acceptable reason, so speed is not mentioned. Each claim is true
of this build: `data/SafSource` already provides the folder-tree fallback, and the app's only
`MediaStore` use is the best-effort delete request (section 5's permission audit), so neither API is
being dismissed as unnecessary. What they cannot do is *enumerate every volume*, *present one
hierarchy spanning internal storage and removable media*, or *manage arbitrary non-media files and
folders* — which is what "file manager" means, and why the listing copy leads with it.
If a future form allows fewer characters, cut the last sentence first: the fallback is a point of
honesty, not the justification.
- [ ] Upload `store-assets/video/permission-use.mp4` (**19.7 s**, captions burned in) to YouTube
**Unlisted** or Drive shared with anyone who has the link, then paste that URL into the form's
video field. The repository file is not a link, and a private video cannot be reviewed. What it
shows, why the system UI is German, and how to re-shoot it: [PERMISSION_VIDEO](PERMISSION_VIDEO.md).
### The foreground-service declaration (`dataSync`)
`FileOpService` is a **`dataSync`** foreground service: it keeps a copy, move, delete (into the trash),
compress or extract alive while the user is in another app, and mirrors the engine's progress into an
ongoing, silent notification. It runs no network code and the app has no `INTERNET` permission. Play
asks about this separately from All files access.
- **Use case: Local processing → Importing, exporting.** Play's own chart describes the use case as
*"Includes migration of files from SD card. Show the user manually triggering an import or export
of files locally (for example, as moving files from an SD card)"* — which is what **Copy to…** and
**Cut to…** do. Do **not** tick network processing, backing up/restoring, media
transcoding or "Other tasks": every `OperationKind` is local — `COPY`, `MOVE`, `DELETE`, `COMPRESS`,
`EXTRACT` — and none of them touches a network.
- **Functionality description (482 / 500 characters, measured):**
```
Plain Files is a file manager. Its dataSync service runs user-initiated local file operations: copying and moving files and folders between folders, internal storage, SD cards and USB drives; deleting into a 24-hour trash; creating ZIPs; extracting ZIP, TAR, GZ, BZ2, XZ and 7z archives. It keeps a long operation alive while the user switches apps and posts an ongoing, silent progress notification until the work ends. All operations are local; the app has no INTERNET permission.
```
- **If the task is deferred** (*"does not start immediately"*): *"If deferred, a copy, move, delete,
compress or extract the user just confirmed would not start while Plain Files is in the background:
no progress notification and no visible change until the app returns. A large operation could look
as if it had silently failed, and the user might start it again."*
- **If the task is interrupted** (*"paused and/or restarted"*): *"If interrupted, the work stops.
Items already completed remain, but a file being copied or extracted can be left partly written at
the destination, and the user has to start the operation again. Nothing is overwritten without
asking, and deletes are staged in the 24-hour trash, so completed deletes remain recoverable."*
- [ ] **Upload the foreground-service video.** `store-assets/video/fgs-import-export.mp4` (**16.1 s**,
captions burned in) to YouTube **Unlisted** or Drive with link sharing, then paste that URL into
the foreground-service declaration. It shows the selection, **Copy to…**, the destination
picker (`Internal storage → AADemo → Target`), **Copy here**, the **Copying files**
notification with its progress bar, and **Copied 121 items to Target**. It is a
different field from the All files access video. What was cut, the privacy checks, and how to
re-shoot it: [FGS_VIDEO](FGS_VIDEO.md).
**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.
The per-release **What's new** field is separate from the listing and is capped at **500 characters**:
its copy, measured, is in [RELEASE_NOTES](RELEASE_NOTES.md), together with the longer announcement
for a website or GitHub release.
- [ ] 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).