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.
@@ -9,6 +9,10 @@ android {
defaultConfig {
minSdk = 26
// the plugin's own R8 keep rules, handed to whatever app links this
// module — inert unless that app minifies, which only the release
// build type does
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
@@ -0,0 +1,22 @@
# Consumer R8/ProGuard rules for the media-session plugin. Inert unless the
# consuming app minifies, and only the `release` build type does, so the
# debug APK the deploy pipeline builds is untouched by this file.
#
# MediaSessionPlugin is found by the string
# "com/soulstream/mediasession/MediaSessionPlugin" (register_android_plugin →
# FindClass), constructed through "(Landroid/app/Activity;)V", and its two
# @Command methods — update and clear — are dispatched by NAME through
# PluginHandle's commands map. NowPlayingArgs is filled by Jackson field by
# field. Rename any of it and the app still builds, installs and plays; the
# lock-screen card simply never appears.
#
# The other half of this plugin is in the app-wide file: the card's buttons
# come back as the plugin event "action", which the WebView subscribes to
# through Plugin.registerListener — declared on tauri's base class, which no
# rule of tauri's own names. See src-tauri/android/proguard-soulstream.pro.
#
# MediaService and androidx's MediaButtonReceiver are declared in this
# module's AndroidManifest.xml, so AGP's own merged-manifest keep rules hold
# them by name. Not repeated here: one place decides each of them.
-keep class com.soulstream.mediasession.MediaSessionPlugin { *; }
-keep class com.soulstream.mediasession.NowPlayingArgs { *; }
@@ -9,6 +9,10 @@ android {
defaultConfig {
minSdk = 26
// the plugin's own R8 keep rules, handed to whatever app links this
// module — inert unless that app minifies, which only the release
// build type does
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
@@ -0,0 +1,17 @@
# Consumer R8/ProGuard rules for the share plugin. Inert unless the consuming
# app minifies, and only the `release` build type does, so the debug APK the
# deploy pipeline builds is untouched by this file.
#
# SharePlugin is found by the string "com/soulstream/share/SharePlugin"
# (register_android_plugin → FindClass), constructed through
# "(Landroid/app/Activity;)V", and its one @Command method — pendingShare —
# is dispatched by NAME through PluginHandle's commands map. It takes no
# arguments, so there is no @InvokeArg class here. Rename it and text shared
# to SoulStream from another app arrives nowhere: the activity still runs and
# still brings the app to the front, and the WebView is never handed what it
# carried.
#
# ShareActivity is declared in this module's AndroidManifest.xml, so AGP's
# own merged-manifest keep rules hold it by name. Not repeated here: one
# place decides it.
-keep class com.soulstream.share.SharePlugin { *; }
@@ -9,6 +9,10 @@ android {
defaultConfig {
minSdk = 26
// the plugin's own R8 keep rules, handed to whatever app links this
// module — inert unless that app minifies, which only the release
// build type does
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
@@ -0,0 +1,38 @@
# Consumer R8/ProGuard rules for the storage plugin. Gradle hands a consumer
# file to whatever app links this module, and it is INERT unless that app
# minifies: src-tauri/gen/android/app/build.gradle.kts sets
# isMinifyEnabled = true for the `release` build type and false for `debug`,
# and the debug type names no proguard files at all. The deploy pipeline
# builds debug, so nothing in this file reaches it.
#
# Nothing below is called from Kotlin, which is the whole problem. Tauri
# reaches this plugin by STRING, at three layers:
#
# 1. register_android_plugin("com.soulstream.storage", "StoragePlugin") is
# turned into "com/soulstream/storage/StoragePlugin" and handed to
# FindClass; the instance is built through the JNI signature
# "(Landroid/app/Activity;)V" (tauri/src/plugin/mobile.rs).
# 2. PluginHandle.indexMethods() files every @Command under
# commands[method.name] and every @ActivityCallback under
# startActivityCallbackMethods[method.name]; invoke() looks up the name
# Rust asked for and throws "No command pickFolder found for plugin ..."
# when it is not there.
# 3. Jackson fills the @InvokeArg classes FIELD by field — PluginManager
# sets FIELD visibility to ANY — so field names weigh the same as
# method names.
#
# Six commands (pickFolder, allFilesAccess, requestAllFilesAccess, pickImage,
# pickFile, createDocument) and four activity callbacks (folderPicked,
# imagePicked, filePicked, documentCreated). createDocument is the easy one
# to miss by hand: it has no JS-facing command at all, it is called from Rust
# by playlist export.
#
# Tauri's own consumer rules already match all of this by annotation
# (-keep @TauriPlugin public class * { @Command public <methods>; ... }).
# These name it outright anyway: theirs are generic, ours are specific, the
# failure is silent and user-facing, and nobody has built a release APK of
# this app yet to find out which of us was needed. Narrow them once a release
# build has actually been smoke-tested command by command.
-keep class com.soulstream.storage.StoragePlugin { *; }
-keep class com.soulstream.storage.CreateDocumentArgs { *; }
-keep class com.soulstream.storage.PickFileArgs { *; }
@@ -9,6 +9,10 @@ android {
defaultConfig {
minSdk = 26
// the plugin's own R8 keep rules, handed to whatever app links this
// module — inert unless that app minifies, which only the release
// build type does
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
@@ -0,0 +1,22 @@
# Consumer R8/ProGuard rules for the transfer-notice plugin. Inert unless the
# consuming app minifies, and only the `release` build type does, so the
# debug APK the deploy pipeline builds is untouched by this file.
#
# TransferNoticePlugin is found by the string
# "com/soulstream/transfernotice/TransferNoticePlugin"
# (register_android_plugin → FindClass), constructed through
# "(Landroid/app/Activity;)V", and its two @Command methods — update and
# clear — are dispatched by NAME through PluginHandle's commands map.
# NoticeArgs is filled by Jackson field by field. Rename any of it and a Save
# runs to completion with no notice in the shade at all.
#
# The notice's own Cancel and Open come back as the plugin event "action",
# which the WebView subscribes to through Plugin.registerListener — declared
# on tauri's base class, which no rule of tauri's own names. See
# src-tauri/android/proguard-soulstream.pro.
#
# TransferService is declared in this module's AndroidManifest.xml, so AGP's
# own merged-manifest keep rules hold it by name. Not repeated here: one
# place decides it.
-keep class com.soulstream.transfernotice.TransferNoticePlugin { *; }
-keep class com.soulstream.transfernotice.NoticeArgs { *; }
+21 -5
View File
@@ -1,4 +1,4 @@
android: the keep rules for a release build nobody has made
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
@@ -29,7 +29,23 @@ 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. None of this has been built: Gradle, Kotlin and R8 do not run on this
machine, the rules are a precaution against a silent failure rather than a fix
for an observed one, and ANDROID.md says so and lists what to test by hand,
command by command, on the first release build anyone makes.
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.
+2 -2
View File
@@ -220,8 +220,8 @@ const A_DOC_LOGLINE = '`MediaSessionPlugin.update` logs one `media window n=…
'bridge wants camelCase arrives as nothing at all on both hops, with no error' + NL +
'anywhere, which is why the art-key count is compared and not just the size and' + NL +
'the index.';
const A_DOC_TAIL = ' and should work down the smoke list in "The release build" before believing' + NL +
' any of it.';
const A_DOC_TAIL = ' the share sheet, which no walk reaches. Work down the smoke list in "The' + NL +
' release build" before trusting a release APK on a phone.';
for (const [file, anchors] of [
[PLUGIN + 'android/build.gradle.kts', [A_GRADLE]],
+63 -4
View File
@@ -4,12 +4,46 @@ set -euo pipefail
cd /f/Coding/SoulStream
source src-tauri/android/env.sh
# Which build. The default is exactly what this script has always done, and
# has to stay that way: deploy.sh calls it with no argument every twenty
# minutes, and ship.sh calls deploy.sh. `--release` (or SS_ANDROID_RELEASE=1)
# is the other path, and as of this round nobody has run it — see ANDROID.md
# → "The release build (nobody has made one yet)".
REL=0
case "${1:-}" in
--release) REL=1 ;;
"") ;;
*) echo "usage: build.sh [--release]"; exit 2 ;;
esac
if [ "${SS_ANDROID_RELEASE:-0}" = "1" ]; then REL=1; fi
if [ "$REL" = "1" ]; then
PROFILE=release
VARIANT=Arm64Release
# --features is repeatable and cargo unions them, so custom-protocol on
# the cargo line below is kept. devtools is not a nicety: wry compiles the
# setWebContentsDebuggingEnabled call only under
# cfg(any(debug_assertions, feature = "devtools")), so a plain release APK
# opens no webview_devtools_remote socket and `smoke.mjs --phone` cannot
# attach to the one build that most needs walking. R8 never sees it.
CARGO_EXTRA="--release --features tauri/devtools"
test -d src-tauri/gen/android/app || { echo "no generated Android project — run the CLI's android init first"; exit 1; }
# The app-wide keep rules, into a project that cannot hold them itself:
# gen/ is regenerated and gitignored. The release build type globs
# **/*.pro out of this directory; the debug type reads none of them.
cp src-tauri/android/proguard-soulstream.pro src-tauri/gen/android/app/proguard-soulstream.pro
echo "== RELEASE: R8 on, rules from src-tauri/android/proguard-soulstream.pro + each plugin's consumer-rules.pro"
else
PROFILE=debug
VARIANT=Arm64Debug
CARGO_EXTRA=""
fi
echo "== cargo $(date +%T)"
cargo build --lib -p app --target aarch64-linux-android --features tauri/custom-protocol 2>&1 | grep -v "^\s*Compiling\|^\s*Fresh"
cargo build --lib -p app --target aarch64-linux-android --features tauri/custom-protocol $CARGO_EXTRA 2>&1 | grep -v "^\s*Compiling\|^\s*Fresh"
# the grep above hides cargo's exit code; a failed compile must not package
# the previous library as if it were this one
test "${PIPESTATUS[0]}" -eq 0 || { echo "cargo failed — not packaging"; exit 1; }
SO=target/aarch64-linux-android/debug/libapp_lib.so
SO=target/aarch64-linux-android/$PROFILE/libapp_lib.so
test -f "$SO" || { echo "no .so built"; exit 1; }
echo "== strip $(date +%T)"
DEST=src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a
@@ -19,8 +53,33 @@ cp "$SO" "$DEST/libapp_lib.so"
ls -la "$DEST/libapp_lib.so"
echo "== gradle $(date +%T)"
cd src-tauri/gen/android
./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug -q 2>&1 | grep -v "^$" | tail -20 || true
./gradlew.bat assemble$VARIANT -x rustBuild$VARIANT -q 2>&1 | grep -v "^$" | tail -20 || true
cd /f/Coding/SoulStream
APK=src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk
if [ "$REL" = "1" ]; then
UNSIGNED=src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release-unsigned.apk
if [ ! -f "$UNSIGNED" ]; then
echo "no release APK — gradle's tail is above. The two failures we expect:"
echo " R8 missing class — its own list is at src-tauri/gen/android/app/build/outputs/mapping/arm64Release/missing_rules.txt;"
echo " add what it names to src-tauri/android/proguard-soulstream.pro and run again"
echo " lintVital — re-run gradle by hand with -x lintVitalArm64Release"
echo " (AGP 8 splits it; -x lintVitalAnalyzeArm64Release may be the one)"
exit 1
fi
KS="$HOME/.android/debug.keystore"
test -f "$KS" || { echo "no $KS — the release APK is unsigned and no phone will install it"; exit 1; }
BT="$(ls -d "$SDK"/build-tools/* 2>/dev/null | sort -V | tail -1 || true)"
test -n "$BT" || { echo "no build-tools under $SDK — cannot sign"; exit 1; }
APK=src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release.apk
echo "== sign $(date +%T) ($(basename "$BT"))"
"$BT/zipalign.exe" -p -f 4 "$UNSIGNED" "$APK"
# The SAME key the debug builds are signed with, deliberately. A release
# APK signed with any other key cannot be installed over the app already
# on the phone, and the obvious next move — uninstall, then install —
# takes the app's data with it. This is a test build, not a distributable
# one; a real one needs a real key and this script is not where that goes.
MSYS_NO_PATHCONV=1 "$BT/apksigner.bat" sign --ks "$(cygpath -w "$KS")" --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "$APK"
else
APK=src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk
fi
ls -la "$APK"
echo "== done $(date +%T)"
+96
View File
@@ -0,0 +1,96 @@
# The app-wide R8 rules for a SoulStream release APK — the ones that belong
# to no single plugin.
#
# This file lives in the repo on purpose. The Gradle project that consumes it,
# src-tauri/gen/android/app, is GENERATED and gitignored, and
# `tauri android init` rewrites it, so a rule written into that project's own
# proguard-rules.pro is a rule with a short life.
# `bash src-tauri/android/build.sh --release` copies this file next to that
# project's build.gradle.kts, whose release build type reads
#
# proguardFiles(*fileTree(".") { include("**/*.pro") }
# .plus(getDefaultProguardFile("proguard-android-optimize.txt")) ...)
#
# — anything ending in .pro in that directory. Copying it is a no-op for the
# debug build: the debug build type sets isMinifyEnabled = false and names no
# proguard files at all, so the path the deploy pipeline runs every twenty
# minutes never reads a line of this.
#
# NOR are the rules for the wry/JNI bridge here. wry's build script writes
# proguard-wry.pro next to the Kotlin it generates, in
# app/src/main/java/com/soulstream/app/generated/, and the release build type's
# **/*.pro glob is recursive, so it is already picked up: Rust's native
# methods, WryActivity, Ipc's @JavascriptInterface, RustWebView.evalScript.
# proguard-tauri.pro, beside this file, keeps TauriActivity.getPluginManager().
# Duplicating either here would be a second place to get them wrong.
#
# Per-plugin rules are NOT here. Each plugin carries its own
# consumer-rules.pro inside crates/tauri-plugin-*/android, so they travel with
# the plugin into a regenerated gen/android and into a plain
# `npx tauri android build` too.
#
# NOTHING BELOW HAS BEEN PROVEN BY A BUILD. Every APK this project has ever
# made is assembleArm64Debug with minification off. See ANDROID.md →
# "The release build".
# Tauri reads its own annotations off the class files at RUNTIME:
# PluginHandle.indexMethods() asks each Method whether @Command,
# @ActivityCallback and @PermissionCallback are present, and all three are
# @Retention(AnnotationRetention.RUNTIME). proguard-android-optimize.txt is
# expected to keep them with `-keepattributes *Annotation*`, but that file is
# extracted from the Android Gradle plugin when a release build runs and no
# release build has ever run here, so it has never existed in this tree to
# read. Say it outright rather than inherit it from a file this project does
# not own and cannot see. Signature, InnerClasses
# and EnclosingMethod are for Jackson, which resolves an @InvokeArg field's
# declared type before it fills it.
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault,Signature,InnerClasses,EnclosingMethod
# THE GAP. Tauri's own consumer rule is
#
# -keep @app.tauri.annotation.TauriPlugin public class * {
# @app.tauri.annotation.Command public <methods>; ... }
#
# and a class-member specification matches the members a matched class
# DECLARES. These four @Command methods are declared on app.tauri.plugin.Plugin
# — the abstract base every plugin extends — which carries no @TauriPlugin of
# its own; tauri's other rule for that package keeps only @JniMethod and
# native members. So no rule in the build names them, while
# PluginHandle.indexMethods() walks the superclass chain with
# getDeclaredMethods() and registers them by name regardless.
#
# registerListener is the one this app rides on. addPluginListener() in
# src/mobile/media.svelte.js and src/mobile/xfernote.svelte.js invokes
# plugin:media-session|registerListener and
# plugin:transfer-notice|registerListener, and those subscriptions are how the
# lock-screen card's prev/play/next/stop and the download notice's cancel and
# open get back into the app. Lose them and everything still looks right and
# nothing either of those buttons does works.
#
# Written as a precaution, not as a diagnosed bug: whether R8 reaches
# inherited members through tauri's rule could not be settled from source, and
# it cannot be settled on this machine at all. Six lines to stop asking.
-keep class app.tauri.plugin.Plugin {
public void registerListener(app.tauri.plugin.Invoke);
public void removeListener(app.tauri.plugin.Invoke);
public void checkPermissions(app.tauri.plugin.Invoke);
public void requestPermissions(app.tauri.plugin.Invoke);
}
# jackson-databind 2.15.3 — tauri's own dependency, the thing that fills every
# @InvokeArg — ships no META-INF/proguard rules of its own, and its
# databind/ext/Java7SupportImpl references java.beans.ConstructorProperties and
# java.beans.Transient, neither of which exists on Android. R8 in AGP 7+ turns
# a missing class into a BUILD ERROR, so without these two lines the release
# build may not get as far as producing an APK.
#
# If R8 names others, its own list is the answer: add what
# app/build/outputs/mapping/arm64Release/missing_rules.txt says to this file
# and run again. (jackson-core 2.15.3 scanned clean, and the
# java.lang.reflect.RecordComponent in jdk14/JDK14Util is a reflective string
# rather than a class reference, so it should not be reported.)
-dontwarn java.beans.ConstructorProperties
-dontwarn java.beans.Transient