From 8d2109b87ede28ac823e34d683607f6a4b9861b6 Mon Sep 17 00:00:00 2001 From: Logan Date: Thu, 20 Aug 2026 14:34:59 -0400 Subject: [PATCH] fix(android): install and launch the package the APK declares The four adb-driven tasks in build/android/Taskfile.yml began with `adb uninstall {{.APP_ID}}`, where APP_ID defaulted to "app.yellowjacket" -- the release id. `run` and `run:device` build the *debug* variant, whose applicationIdSuffix makes it "app.yellowjacket.dev", so both uninstalled the user's released app, took the library with it, installed a different package, and then failed to launch the one they had just removed. The id is read back from the built APK now (scripts/android-pkgid.sh, `aapt2 dump packagename`) rather than written down a second time, so the thing installed and the thing launched agree by construction -- whatever Gradle resolved the applicationId to, suffixes included, is in the file. An APK it cannot read is a hard failure and never a fallback to a default; guessing is the bug. APP_ID survives with no default as an *assertion*: it is checked against the artifact and refused, naming both, before anything is installed or a target is even chosen. The uninstall is gone rather than corrected. It was there to make the bare `install` on the next line work at all -- Android refuses an install over an existing package without -r -- so `install -r` removes the reason for it. What is left is the one case an uninstall is really the remedy, a changed signing certificate, and that is exactly the case where doing it silently costs the user their library. So it is reported with the command to run, which is the answer scripts/android-emulator.sh had already reached for `make android-install`. And the emulator tasks now say "emulator" to adb. A bare `adb install` with one device attached picks that device whatever it is, so with a phone plugged in and no emulator running, the task whose summary reads "in the Android Emulator" installed on the phone -- the same data loss, from the task whose name gives no warning. Several matching targets is an error naming them rather than a silent pick of the first. Closes #159 --- build/android/Taskfile.yml | 80 +++++---------- scripts/android-deploy.sh | 196 +++++++++++++++++++++++++++++++++++++ scripts/android-pkgid.sh | 97 ++++++++++++++++++ 3 files changed, 318 insertions(+), 55 deletions(-) create mode 100755 scripts/android-deploy.sh create mode 100755 scripts/android-pkgid.sh diff --git a/build/android/Taskfile.yml b/build/android/Taskfile.yml index b157ca3..c486463 100644 --- a/build/android/Taskfile.yml +++ b/build/android/Taskfile.yml @@ -4,18 +4,28 @@ includes: common: ../Taskfile.yml vars: - # The *installed* package name, which every adb-driven task below uses - # to uninstall, launch and filter. It must agree with `applicationId` - # in app/build.gradle, and nothing enforces that. + # APP_ID is an *assertion*, not a setting, and it has no default. # - # ANDROID.md says to set this in build/config.yml. That does not work - # in beta.8, checked both ways: `wails3 task` builds its var set from - # CLI KEY=VALUE arguments and the Taskfile tree only -- nothing reads - # config.yml -- and even when set it feeds only these adb commands, - # never Gradle. So the identity is declared twice, here and in - # build.gradle, and a change to one alone means the official run and - # deploy tasks address a package that is not installed. - APP_ID: '{{.APP_ID | default "app.yellowjacket"}}' + # It used to be the id every adb-driven task below uninstalled, + # launched and filtered, defaulting to "app.yellowjacket". It could + # never have been a setting: `wails3 task` builds its var set from CLI + # KEY=VALUE arguments and the Taskfile tree only -- nothing reads + # build/config.yml, contrary to ANDROID.md, checked with --dry -- and + # even when set it fed only the adb commands, never Gradle. So the + # identity was declared twice, here and as `applicationId` in + # app/build.gradle, with nothing enforcing that they agree. + # + # They did not agree. The debug buildType carries + # `applicationIdSuffix ".dev"`, so the tasks that assemble a debug APK + # addressed the *release* id -- on a device, the user's installed app + # and their library (#159). + # + # The id is now read back from the built APK by scripts/android- + # pkgid.sh, so the thing installed and the thing launched agree by + # construction. Passing APP_ID= says "this build had better declare + # that id", and the deploy refuses before touching anything if it does + # not -- which is the check that would have caught #159 statically. + APP_ID: '{{.APP_ID | default ""}}' MIN_SDK: '21' TARGET_SDK: '35' # The emulator runs the host architecture; physical devices are arm64 @@ -372,9 +382,7 @@ tasks: ARCH: '{{.ARCH | default .HOST_ARCH}}' cmds: - task: ensure-emulator - - '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true' - - '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"' - - '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity' + - './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}' run: summary: Build, install and launch a debug build in the Android Emulator @@ -383,9 +391,7 @@ tasks: - task: build cmds: - task: assemble:apk - - '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true' - - '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"' - - '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity' + - './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}' device:list: summary: Lists connected Android devices and emulators (serials) @@ -400,25 +406,7 @@ tasks: ARCH: arm64 cmds: - task: assemble:apk - - | - DEVICE='{{.DEVICE_ID | default ""}}' - if [ -z "$DEVICE" ]; then - DEVICE="${DEVICE_ID:-}" - fi - if [ -z "$DEVICE" ]; then - DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }') - fi - if [ -z "$DEVICE" ]; then - echo "Error: no connected physical Android device found." - echo "Pass DEVICE_ID= to target a device explicitly." - echo "Find connected device serials with: {{.ADB}} devices" - exit 1 - fi - - echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..." - "{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true - "{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk" - "{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity + - './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target device{{if .DEVICE_ID}} --serial "{{.DEVICE_ID}}"{{end}}{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}' preconditions: - sh: '[ -x "{{.ADB}}" ] || command -v adb' msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" @@ -430,25 +418,7 @@ tasks: vars: ARCH: arm64 cmds: - - | - DEVICE='{{.DEVICE_ID | default ""}}' - if [ -z "$DEVICE" ]; then - DEVICE="${DEVICE_ID:-}" - fi - if [ -z "$DEVICE" ]; then - DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }') - fi - if [ -z "$DEVICE" ]; then - echo "Error: no connected physical Android device found." - echo "Pass DEVICE_ID= to target a device explicitly." - echo "Find connected device serials with: {{.ADB}} devices" - exit 1 - fi - - echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..." - "{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true - "{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk" - "{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity + - './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target device{{if .DEVICE_ID}} --serial "{{.DEVICE_ID}}"{{end}}{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}' preconditions: - sh: '[ -x "{{.ADB}}" ] || command -v adb' msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" diff --git a/scripts/android-deploy.sh b/scripts/android-deploy.sh new file mode 100755 index 0000000..4670cbc --- /dev/null +++ b/scripts/android-deploy.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# Install a built APK onto an Android target and launch it, under the +# package id the APK itself declares. +# +# This is the whole body of build/android/Taskfile.yml's four adb-driven +# tasks — deploy-emulator, run, run:device, deploy-device — which were +# three lines each, written out four times, and wrong in two ways in all +# four (#159): +# +# adb uninstall app.yellowjacket # the RELEASE id, unconditionally +# adb install bin/yellowjacket.apk +# adb shell am start -n app.yellowjacket/com.wails.app.MainActivity +# +# **The uninstall is not here and does not come back.** It was there to +# make the bare `install` on the next line work at all — without -r, +# Android refuses an install over an existing package — so `install -r` +# removes the reason for it rather than merely removing it. What is +# left is the one case an uninstall really is the remedy, a changed +# signing certificate, and that is exactly the case where performing it +# silently costs the user their library. So it is *named* and not done: +# an error message carrying the command is a decision the person at the +# keyboard gets to make, which is the same answer scripts/android- +# emulator.sh already reached for `make android-install`. +# +# **The id is read back from the artifact**, never defaulted, so the +# thing installed and the thing launched cannot disagree — see +# scripts/android-pkgid.sh for why that is by construction rather than +# by discipline. +# +# **The target is checked against the task's own name.** The emulator +# tasks used a bare `adb`, which with one device attached picks that +# device whatever it is — so `wails3 task android:run`, whose summary +# says "in the Android Emulator", installed on the phone when a phone +# was the only thing plugged in. A task addressing something other than +# what it says is the same fault as the package id, one level up. +# +# Usage: +# android-deploy.sh --apk --target emulator|device|any \ +# [--expect ] [--serial ] [--no-launch] +set -euo pipefail + +cd "$(dirname "$0")/.." + +SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" +ADB="$(command -v adb || echo "$SDK/platform-tools/adb")" + +# **Not "$PKG/.MainActivity".** A leading-dot activity is resolved +# against the applicationId, and the scaffold's activity lives in the +# Java package com.wails.app, which is deliberately not it. The short +# form fails with a class-not-found that reads like a broken build. +ACTIVITY="${YJ_ANDROID_ACTIVITY:-com.wails.app.MainActivity}" + +APK="" +TARGET="any" +EXPECT="" +SERIAL="${ANDROID_SERIAL:-${DEVICE_ID:-}}" +LAUNCH=1 + +die() { echo "android-deploy: $*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --apk) APK="${2:-}"; shift 2 ;; + --target) TARGET="${2:-}"; shift 2 ;; + --expect) EXPECT="${2:-}"; shift 2 ;; + --serial) SERIAL="${2:-}"; shift 2 ;; + --no-launch) LAUNCH=0; shift ;; + *) die "unknown option $1" ;; + esac +done + +[ -n "$APK" ] || die "--apk is required" +[ -f "$APK" ] || die "no such APK: $APK + Build one first: wails3 task android:assemble:apk (debug) + wails3 task android:package (release)" +[ -x "$ADB" ] || command -v adb >/dev/null || + die "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" + +case "$TARGET" in +emulator | device | any) ;; +*) die "--target must be emulator, device or any (got '$TARGET')" ;; +esac + +# ---------------------------------------------------------------- # +# Which package +# ---------------------------------------------------------------- # + +# This runs *before* a target is chosen, deliberately: the guard is a +# question about the artifact, so it can be answered — and exercised — +# with nothing plugged in, and a build whose id is wrong should be +# refused whether or not there is anything to install it onto. +# +# An unreadable APK, or an id that is not the one the caller named, is a +# hard stop before anything is installed or launched. Spelled as two +# calls rather than one with a conditional argument: an empty array under +# `set -u` is an unbound variable in bash 3.2, which is what macOS ships. +if [ -n "$EXPECT" ]; then + PKG="$(./scripts/android-pkgid.sh "$APK" --expect "$EXPECT")" +else + PKG="$(./scripts/android-pkgid.sh "$APK")" +fi + +# ---------------------------------------------------------------- # +# Which target +# ---------------------------------------------------------------- # + +# An emulator serial is "emulator-"; anything else online is a +# physical device. That is the same test the device tasks already made, +# and the emulator tasks did not make at all. +online_matching() { + case "$TARGET" in + emulator) "$ADB" devices | awk 'NR > 1 && $2 == "device" && $1 ~ /^emulator-/ { print $1 }' ;; + device) "$ADB" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1 }' ;; + any) "$ADB" devices | awk 'NR > 1 && $2 == "device" { print $1 }' ;; + esac +} + +if [ -z "$SERIAL" ]; then + matches="$(online_matching)" + count="$(printf '%s' "$matches" | grep -c . || true)" + + if [ "$count" -eq 0 ]; then + echo "android-deploy: no ${TARGET/any/attached} target is online." >&2 + "$ADB" devices | sed '1d;/^$/d;s/^/ /' >&2 || true + if [ "$TARGET" = "emulator" ]; then + echo " Start one with: make android-emulator" >&2 + elif [ "$TARGET" = "device" ]; then + echo " Plug a phone in and authorise the adb key." >&2 + fi + exit 1 + fi + + # Several is ambiguous, and picking the first silently is how a + # build lands on a target nobody named. The old run:device did + # exactly that. + if [ "$count" -gt 1 ]; then + echo "android-deploy: several $TARGET targets are online — name one." >&2 + printf '%s\n' "$matches" | sed 's/^/ /' >&2 + echo " Pass DEVICE_ID=, or set ANDROID_SERIAL." >&2 + exit 1 + fi + + SERIAL="$matches" +fi + +# ---------------------------------------------------------------- # +# Install +# ---------------------------------------------------------------- # + +echo "android-deploy: $APK ($PKG) -> $SERIAL" + +if ! out="$("$ADB" -s "$SERIAL" install -r "$APK" 2>&1)"; then + printf '%s\n' "$out" + case "$out" in + *INSTALL_FAILED_UPDATE_INCOMPATIBLE* | *"signatures do not match"*) + cat >&2 <&2 < [--expect ] +# +# Exit codes: 0 printed the id; 1 could not read it; 2 --expect failed. +set -euo pipefail + +die() { echo "android-pkgid: $*" >&2; exit 1; } + +APK="" +EXPECT="" +while [ $# -gt 0 ]; do + case "$1" in + --expect) EXPECT="${2:-}"; shift 2 ;; + -*) die "unknown option $1" ;; + *) APK="$1"; shift ;; + esac +done + +[ -n "$APK" ] || die "usage: android-pkgid.sh [--expect ]" +[ -f "$APK" ] || die "no such APK: $APK" + +# aapt2 lives under build-tools//, which is versioned, so it is +# resolved rather than pinned. PATH first, so a system aapt2 (Arch ships +# one) works without an SDK layout at all. +find_aapt() { + local sdk name + for name in "$@"; do + command -v "$name" 2>/dev/null && return 0 + done + sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" + for name in "$@"; do + ls "$sdk"/build-tools/*/"$name" 2>/dev/null | sort -V | tail -1 | grep . && return 0 + done + return 1 +} + +pkg="" + +# `aapt2 dump packagename` answers in one word and is the cheapest of +# the three. aapt1 is the fallback because it is what older build-tools +# carry and what the issue's own measurement used. +if AAPT2="$(find_aapt aapt2)"; then + pkg="$("$AAPT2" dump packagename "$APK" 2>/dev/null | head -1 | tr -d '\r')" || true +fi + +if [ -z "$pkg" ] && AAPT="$(find_aapt aapt)"; then + pkg="$("$AAPT" dump badging "$APK" 2>/dev/null | + sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1)" || true +fi + +# Guessing here is the bug this file exists to prevent, so an unreadable +# APK is a hard failure and never a fallback to a written-down default. +if [ -z "$pkg" ]; then + die "could not read a package name from $APK. + Install the SDK build-tools (aapt2), or set ANDROID_HOME to an SDK + that carries them: sdkmanager 'build-tools;34.0.0'" +fi + +if [ -n "$EXPECT" ] && [ "$EXPECT" != "$pkg" ]; then + cat >&2 <