diff --git a/CLAUDE.md b/CLAUDE.md index cff46826..30597d47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,12 @@ bun run android:logs # logcat The **debug** build type carries `applicationIdSuffix ".debug"`, so `com.dtourolle.jellytau.debug` ("JellyTau Debug") installs *alongside* a release build with its own data dir — never uninstall the release app to test a debug -one. Only the applicationId is suffixed; Kotlin classes stay in the `namespace` -package `com.dtourolle.jellytau`, so JNI lookups and R8 keep rules are -unaffected. See [README_ANDROID_BUILD.md](src-tauri/android/README_ANDROID_BUILD.md). +one. `./scripts/build-and-deploy.sh release --device --debug` puts an +R8-minified *release* build in that same slot, signed with the local debug +keystore, for validating minification without the real key. Only the +applicationId is suffixed; Kotlin classes stay in the `namespace` package +`com.dtourolle.jellytau`, so JNI lookups and R8 keep rules are unaffected. See +[README_ANDROID_BUILD.md](src-tauri/android/README_ANDROID_BUILD.md). CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI only against the mirror if one exists; the canonical remote is diff --git a/scripts/build-and-deploy.sh b/scripts/build-and-deploy.sh index 41d07e36..1ab18af2 100755 --- a/scripts/build-and-deploy.sh +++ b/scripts/build-and-deploy.sh @@ -11,11 +11,13 @@ echo "" echo "" -# Deploy APK — extract build type (default debug), ignoring flags like --clean. -BUILD_TYPE="debug" +# Deploy APK — forward the build type and the side-by-side flag (which decides +# which package to launch), ignoring build-only flags like --clean and --device. +DEPLOY_ARGS=("debug") for arg in "$@"; do case "$arg" in - debug|release) BUILD_TYPE="$arg" ;; + debug|release) DEPLOY_ARGS[0]="$arg" ;; + --debug|--side-by-side) DEPLOY_ARGS+=("--side-by-side") ;; esac done -./scripts/deploy-android.sh "$BUILD_TYPE" +./scripts/deploy-android.sh "${DEPLOY_ARGS[@]}" diff --git a/scripts/build-android.sh b/scripts/build-android.sh index 3b876720..382dc03d 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -23,9 +23,18 @@ echo "" # which is what a distributable universal APK needs — but for an on-device test # it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build # only the connected device's architecture; --abi targets one explicitly. +# +# Side-by-side: the `debug` build type always installs as +# com.dtourolle.jellytau.debug ("JellyTau Debug"), so it never collides with a +# real install. `release --debug` puts a *release* build — R8-minified, exactly +# what ships — into that same slot, signed with the local debug keystore. That +# is how you validate minification (R8 stripping JNI-loaded classes has broken +# release APKs here before) without the real signing key and without +# uninstalling the app you actually use. BUILD_TYPE="debug" CLEAN="${CLEAN:-0}" ABI="${ABI:-}" +SIDE_BY_SIDE="${SIDE_BY_SIDE:-0}" next_is_abi=0 for arg in "$@"; do if [ "$next_is_abi" = "1" ]; then @@ -37,10 +46,17 @@ for arg in "$@"; do --clean) CLEAN=1 ;; --abi) next_is_abi=1 ;; --device) ABI="device" ;; + --debug|--side-by-side) SIDE_BY_SIDE=1 ;; debug|release) BUILD_TYPE="$arg" ;; esac done +# The debug build type is side-by-side unconditionally; the flag only means +# something for a release build. +if [ "$BUILD_TYPE" = "debug" ]; then + SIDE_BY_SIDE=1 +fi + # Resolve --device to the attached device's Rust target triple. if [ "$ABI" = "device" ]; then device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')" @@ -78,7 +94,14 @@ echo "🎨 Building frontend..." bun run build # Step 2: Build Android APK -if [ "$BUILD_TYPE" = "release" ]; then +if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then + # A release build in the debug slot: R8 still runs, but the applicationId is + # suffixed and the debug keystore signs it (read by build.gradle.kts from + # JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other + # .debug install cleanly. Deliberately does NOT write keystore.properties. + echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..." + JT_SIDE_BY_SIDE=1 bun run tauri android build --apk true "${TARGET_ARGS[@]}" +elif [ "$BUILD_TYPE" = "release" ]; then # Configure release signing from .env (single source of truth). Must run # after sync-android-sources.sh, since gen/android is (re)generated there. ./scripts/write-keystore-properties.sh diff --git a/scripts/deploy-android.sh b/scripts/deploy-android.sh index 59d7569e..8c55b3f4 100755 --- a/scripts/deploy-android.sh +++ b/scripts/deploy-android.sh @@ -13,24 +13,42 @@ if ! adb devices | grep -q "device$"; then exit 1 fi -# Build type: debug or release (default: debug) -BUILD_TYPE="${1:-debug}" +# Build type: debug or release (default: debug). `--debug` alongside `release` +# means the side-by-side release build — same APK path, but it was packaged +# under the .debug applicationId, so the package to launch differs. +BUILD_TYPE="debug" +SIDE_BY_SIDE=0 +for arg in "$@"; do + case "$arg" in + --debug|--side-by-side) SIDE_BY_SIDE=1 ;; + debug|release) BUILD_TYPE="$arg" ;; + esac +done +[ "$BUILD_TYPE" = "debug" ] && SIDE_BY_SIDE=1 -# The debug build carries applicationIdSuffix ".debug" (see -# src-tauri/android/app/build.gradle.kts), so it is a separate package and -# installs alongside a release build — no uninstall dance needed. +# The .debug applicationId (see src-tauri/android/app/build.gradle.kts) is a +# separate package, so it installs alongside a real release build — no +# uninstall dance needed. if [ "$BUILD_TYPE" = "release" ]; then APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk" - APP_PACKAGE="com.dtourolle.jellytau" else APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk" +fi + +if [ "$SIDE_BY_SIDE" = "1" ]; then APP_PACKAGE="com.dtourolle.jellytau.debug" +else + APP_PACKAGE="com.dtourolle.jellytau" fi # Check if APK exists if [ ! -f "$APK_PATH" ]; then echo "❌ APK not found at: $APK_PATH" - echo "Run './scripts/build-android.sh $BUILD_TYPE' first" + if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then + echo "Run './scripts/build-android.sh release --debug' first" + else + echo "Run './scripts/build-android.sh $BUILD_TYPE' first" + fi exit 1 fi diff --git a/src-tauri/android/README_ANDROID_BUILD.md b/src-tauri/android/README_ANDROID_BUILD.md index 44536954..23a30068 100644 --- a/src-tauri/android/README_ANDROID_BUILD.md +++ b/src-tauri/android/README_ANDROID_BUILD.md @@ -46,10 +46,26 @@ When you need to modify Android/Kotlin files: The **debug** build type sets `applicationIdSuffix = ".debug"` in `app/build.gradle.kts`, so a debug build is a genuinely separate Android app: -| | applicationId | launcher name | versionName | -|---|---|---|---| -| release | `com.dtourolle.jellytau` | jellytau | `0.5.5` | -| debug | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug` | +| build | applicationId | launcher name | versionName | signed with | +|---|---|---|---|---| +| `release` | `com.dtourolle.jellytau` | jellytau | `0.5.5` | real key (`.env`) | +| `release --debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug-release` | debug keystore | +| `debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug` | debug keystore | + +`release --debug` is the **side-by-side release**: fully R8-minified, exactly +what ships, but packaged into the debug slot and signed with the local debug +keystore. It exists because R8 has broken release APKs here before (stripping +JNI-loaded player/security classes), and reproducing that previously meant +building with the real key and clobbering your working install. It shares the +applicationId *and* signature with the plain debug build, so the two replace +each other cleanly; only the versionName suffix tells you which is installed. + +```bash +./scripts/build-and-deploy.sh release --device --debug # build + install it +``` + +The flag is plumbed through as `JT_SIDE_BY_SIDE=1`, read by `build.gradle.kts`. +CI never sets it, so distributable release builds are untouched. That means: @@ -59,7 +75,10 @@ That means: different packages is just two apps. - Each has **its own data directory** — separate settings, credentials, downloads and offline cache. A debug experiment cannot corrupt the state of - the build you actually use. + the build you actually use. This is not optional and cannot be shared: + Android gives each applicationId its own UID and enforces the boundary in the + kernel. (`sharedUserId` is deprecated since API 29 and cannot be added to an + already-installed app anyway.) You log in again in the debug app, once. - Only the *application* id changes. Kotlin classes stay in the `namespace` package `com.dtourolle.jellytau`, so the JNI class lookups in `src-tauri/src/player/android/mod.rs`, the manifest `` entry and the diff --git a/src-tauri/android/app/build.gradle.kts b/src-tauri/android/app/build.gradle.kts index 6bfa12cd..b6cc2de8 100644 --- a/src-tauri/android/app/build.gradle.kts +++ b/src-tauri/android/app/build.gradle.kts @@ -22,6 +22,15 @@ val keystoreProperties = Properties().apply { } } +// Side-by-side release: set by `scripts/build-android.sh release --debug`, which +// exports JT_SIDE_BY_SIDE=1. It puts a fully R8-minified release build into the +// debug applicationId slot, signed with the local debug keystore — so you can +// test what minification actually produces (R8 stripping JNI-loaded classes has +// broken release APKs here before) without the real signing key and without +// uninstalling your working install. Unset in CI, so distributable release +// builds are untouched. +val sideBySideRelease = System.getenv("JT_SIDE_BY_SIDE").let { it == "1" || it == "true" } + android { compileSdk = 36 namespace = "com.dtourolle.jellytau" @@ -76,7 +85,18 @@ android { } } getByName("release") { - if (keystoreProperties.getProperty("storeFile") != null) { + if (sideBySideRelease) { + // Same slot, name and version scheme as the debug build type, + // plus "-release" so you can tell from Settings > Apps which of + // the two is currently sitting there. Signed with the debug + // keystore: it shares a signature with the debug build, so the + // two replace each other cleanly instead of colliding. + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug-release" + manifestPlaceholders["appLabel"] = "JellyTau Debug" + manifestPlaceholders["activityLabel"] = "JellyTau Debug" + signingConfig = signingConfigs.getByName("debug") + } else if (keystoreProperties.getProperty("storeFile") != null) { signingConfig = signingConfigs.getByName("release") } isMinifyEnabled = true