## The release build (nobody has made one yet)

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. Untested — no release build has run here.

**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)`.
