android: the keep rules, and the release build that proves them

Every APK this port has produced is `assembleArm64Debug`, and the generated
project turns R8 on for `release` and only for `release` — while tauri finds a
plugin class by string, files every `@Command` under `commands[method.name]`,
and lets Jackson fill every `@InvokeArg` field by field. Minification breaks
exactly that, and it breaks it without a word: the app compiles, installs,
launches, shows Home, and then a tap on Library folder answers "No command
pickFolder found for plugin ..." in logcat and nothing on screen. So each
plugin module now carries its own `consumer-rules.pro` naming its own
reflected surface — the shape tauri-android and tauri-plugin-dialog already
use in this build, and one that travels with the plugin into a regenerated
`gen/android`, which a rule written into that gitignored tree would not.

What belongs to no single plugin is in `src-tauri/android/proguard-soulstream.pro`,
copied into the generated project by the build script because the app's own
Gradle file is regenerated: the runtime annotation attributes, a `-dontwarn`
pair for the two `java.beans` classes jackson-databind references and Android
does not have (R8 turns a missing class into a build error, so without them
there may be no APK at all), and a keep for the four `@Command` methods
declared on `app.tauri.plugin.Plugin` itself. Tauri's own rule matches classes
annotated `@TauriPlugin` and the members they declare; the base class carries
no such annotation, so nothing names `registerListener` — which is what the
lock-screen card's buttons and the download notice's Cancel and Open ride on.

`build.sh` grows `--release`: the release profile, `--features tauri/devtools`
so the walk can still attach at all, `assembleArm64Release`, and a zipalign and
sign with the same debug key the phone already trusts, so the APK installs over
the app rather than forcing an uninstall that would take Kyle's settings with
it. With no argument the script is what it has always been, cargo line for
cargo line, so deploy.sh and ship.sh keep the path they run every twenty
minutes. And it has been built. Not on the phone and not as arm64 — as **x86_64**, on an
emulator, because that is what this machine can run — but the flavour is the
only difference: the same Gradle, the same R8, the same rules, the same
zipalign and apksigner. `minifyX86_64ReleaseWithR8` succeeded and wrote **no**
`missing_rules.txt` at all, which is the `-dontwarn` pair doing its job;
`lintVitalX86_64Release` passed, so the `-x lintVital` advice is for a failure
that has not happened. The signed APK went over the debug app with
`adb install -r` and left its data alone, exactly as the same-key argument
above says it must.

Then the minified app was walked: Home, the record, playback, the queue, the
sheets, the lyrics, the drags, the export — **131 of 131, no exceptions, no
console errors, every step as expected**, and nothing in logcat about a missing
command or class. That is the failure this round exists to prevent, looked for
and not found.

What it does not cover is the hands-only list in ANDROID.md — the pickers, the
notification buttons, the share sheet — which no walk reaches, and the four
`@Command` methods on `Plugin` itself remain a precaution rather than a
diagnosed bug. The document says so, command by command.
This commit is contained in:
KeelrLeek committed 2026-09-14 11:09:33 -05:00
1 parent 9c3273e6af
commit 07dad65ca5
13 files changed
+452 -11

No files matched your search

+155
View File
@@ -1337,6 +1337,146 @@ the phone in under a second. The page's origin stays `http://tauri.localhost`
— Tauri proxies the dev server through its own scheme on mobile — so the
harness and the app see one origin in both flavours.
## The release build
Every APK in this port is `assembleArm64Debug` — every `deploy.sh`, every
`dev.sh`, every walk, every build on Kyle's phone. The generated project turns
**R8 on for `release` and only for `release`**: in
`src-tauri/gen/android/app/build.gradle.kts` the debug type is
`isMinifyEnabled = false` and names no proguard files, and the release type is
`isMinifyEnabled = true` plus `proguard-android-optimize.txt` and every
`**/*.pro` it finds in the app directory. R8 is the one thing that can break
this app in a way no build can see.
Tauri's Android plugins are reached by NAME, at three layers, none of which
R8 can trace:
- `register_android_plugin("com.soulstream.storage", "StoragePlugin")` becomes
the string `com/soulstream/storage/StoragePlugin` and goes to `FindClass`;
the instance is constructed through the JNI signature
`(Landroid/app/Activity;)V`.
- `PluginHandle.indexMethods()` walks the class and its superclasses with
`getDeclaredMethods()` and files every `@Command` under
`commands[method.name]`, every `@ActivityCallback` under
`startActivityCallbackMethods[method.name]`. `invoke()` looks up the name
Rust or the WebView asked for and throws `No command pickFolder found for
plugin …` when it is not there.
- Jackson fills every `@InvokeArg` class FIELD by field.
So a minified build compiles, installs, launches and shows Home — and then a
tap on Library folder, or the card's next button, does nothing at all. Four
plugin modules expose eleven commands, four activity callbacks and four
argument classes that way.
```bash
bash src-tauri/android/build.sh --release # or SS_ANDROID_RELEASE=1 bash src-tauri/android/build.sh
adb install -r src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release.apk
```
With no argument `build.sh` is what it has always been — the same cargo line,
the same `assembleArm64Debug -x rustBuildArm64Debug`, the same APK path — so
`deploy.sh`, `dev.sh` and `ship.sh` cannot reach the release path by accident.
The flag changes four things and nothing else: `--release` and
`--features tauri/devtools` on the cargo line, the `release` profile's `.so`,
`assembleArm64Release -x rustBuildArm64Release`, and a sign step at the end.
**Where the keep rules live, and why in two places.** Each plugin carries its
own `consumer-rules.pro` in `crates/tauri-plugin-*/android`, registered by one
`consumerProguardFiles("consumer-rules.pro")` line in its `build.gradle.kts` —
the same shape `tauri-android` and `tauri-plugin-dialog` already use in this
build. (`tauri-plugin-opener` ships a `consumer-rules.pro` and never registers
it; the file is inert. That is the mistake this line exists to avoid.) Consumer rules travel with the plugin, so a regenerated
`gen/android` keeps them, and they are inert in the debug APK because a
consumer file only does anything when the consuming app minifies. What belongs
to no single plugin is in `src-tauri/android/proguard-soulstream.pro`, which
`build.sh --release` copies into `src-tauri/gen/android/app/` for the
`fileTree(".")` glob to pick up — it cannot simply live there, because `gen/`
is generated and gitignored and `tauri android init` rewrites it. That file
holds three things: the runtime annotation attributes, a `-dontwarn` pair for
two `java.beans` classes jackson-databind references and Android does not
have (R8 treats a missing class as a build *error*), and a keep for four
`@Command` methods declared on `app.tauri.plugin.Plugin` itself —
`registerListener`, `removeListener`, `checkPermissions`, `requestPermissions`.
Tauri's own rule matches classes annotated `@TauriPlugin` and the members they
declare; the base class carries no such annotation, so nothing names those
four, and `registerListener` is what the lock-screen card's buttons and the
download notice's Cancel/Open ride on. That last rule is a precaution, not a
diagnosed bug — it is six lines, and it removes the question.
**What already keeps the wry bridge, and must not be thrown away.** The
`**/*.pro` in that glob is recursive, and the file it reaches that nobody
thinks about is
`app/src/main/java/com/soulstream/app/generated/proguard-wry.pro`, written
beside the generated Kotlin by wry's own build script. It is what keeps
`Rust`'s native methods, `WryActivity.setWebView`/`getVersion`/`getId`,
`Ipc`'s `@JavascriptInterface` methods and `RustWebView.evalScript` — the
entire JNI bridge between the Rust core and the WebView, all of it reached by
string from Rust. `proguard-tauri.pro` beside it keeps
`TauriActivity.getPluginManager()`. Neither is ours and neither is in git;
both are regenerated. Narrow that glob to a named list and the app stops at a
black screen. The four plugins are the gap because they are the only
name-reached surface nobody else wrote a rule for.
**What is different about a release build, beyond minification.**
- `usesCleartextTraffic` flips to `"false"` (debug sets the placeholder to
`"true"`). The page's own origin is `http://tauri.localhost`, which Tauri
serves itself, so this should not bite — but it is the first thing to check
if the WebView comes up blank.
- Not debuggable, so `run-as com.soulstream.app` is gone: the documented way
of pushing test music into the app's private Library folder does not work
on a release build. Point it at a folder in shared storage instead.
- DevTools exist only because `build.sh --release` passes
`--features tauri/devtools`; wry compiles
`setWebContentsDebuggingEnabled` under
`cfg(any(debug_assertions, feature = "devtools"))`. Without it there is no
`webview_devtools_remote_<pid>` socket and `smoke.mjs --phone` has nothing
to attach to.
- The APK Gradle produces is `app-arm64-release-unsigned.apk` — there is no
`signingConfigs` block in the generated project. `build.sh --release`
zipaligns it and signs it with `~/.android/debug.keystore`
(alias `androiddebugkey`, password `android`), the **same key the debug
builds use**, so `adb install -r` replaces the app in place. Sign it with
any other key and that install fails; the obvious next move — uninstall,
then install — deletes the app's data, which on Kyle's phone means his
settings, his account and his queue. This is a test build, not a
distributable one.
- `cargo build --release` fills a second `target/aarch64-linux-android/release/`
tree; the debug artifacts are untouched, but that cache roughly doubles.
- Both build types package `app/src/main/jniLibs/arm64-v8a/libapp_lib.so`, and
a release run leaves the release library sitting there. `deploy.sh` copies
over it unconditionally, so the next debug deploy is correct — but a Gradle
run by hand in between would package the release library into a debug APK.
- If lint fails on something unrelated, run Gradle by hand with
`-x lintVitalArm64Release`; AGP 8 splits that into an analyze task and a
report task, so `-x lintVitalAnalyzeArm64Release` may be the one that
actually has to go. It did not come up: `lintVitalX86_64Release` passed on
the build described below, so the exclusion is advice for a failure that has
not happened rather than one that has.
**What to smoke-test on a release build, and only on a release build.** The
walks do not cover this: the pipeline reinstalls the debug APK constantly, and
what minification breaks is exactly the part that is hands rather than CDP —
pickers, notifications and a share sheet. Every plugin command, one at a time:
- **storage** (six commands, four activity callbacks) — pick a library folder
(You → Library folder) and check the picked path comes back; the all-files
access row and the system screen its button opens; pick an image (a playlist
cover); pick a file (a playlist import); export a playlist, which is
`createDocument` and has no JS-facing command at all — it is called from
Rust, so it is the one a hand test forgets.
- **media-session** — play something and watch the card appear (`update`); its
prev, play, next and stop, which arrive through `registerListener`; stop
playing and watch it go (`clear`).
- **transfer-notice** — start a Save and watch the notice appear (`update`);
Cancel and Open on the notice, also `registerListener`; let it finish and
watch it go (`clear`).
- **share** — share plain text to SoulStream from another app (`pendingShare`).
A command that has been renamed answers `No command <name> found for plugin
<class>`, which lands in logcat rather than on screen:
`adb logcat --pid=$(adb shell pidof com.soulstream.app)`.
## Verifying
```bash
@@ -1667,3 +1807,18 @@ symptom.
step. Making an account from inside the app is possible — Soulseek makes
one from the first sign-in with a new name — but it would mean handling a
password, which the tour deliberately refuses.
- **A release build on the phone.** R8 has now run and the keep rules hold —
but on an **x86_64** APK, on an emulator, not on `assembleArm64Release` and
not on Kyle's phone. R8 wrote no `missing_rules.txt` at all (so the
`-dontwarn` pair covers what jackson-databind reaches for),
`lintVitalX86_64Release` passed, zipalign and apksigner with
`~/.android/debug.keystore` produced an APK that `adb install -r` put over
the debug app without touching its data, and the minified app launched,
rendered Home, restored its session and walked **131 of 131** with no
exceptions, no console errors and nothing in logcat about a missing command
or class. What that does **not** cover: the arm64 variant and the
`cargo build --release` library the script actually names — only the
flavour differs, and the Gradle, R8 and signing steps are the same ones —
and the hands-only list below, the pickers and the notification buttons and
the share sheet, which no walk reaches. Work down the smoke list in "The
release build" before trusting a release APK on a phone.