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 <