Files
Hermit/scripts/build_apk_android.sh
T
WHC c2320f946b v0.9.0: the Qt front end as an Android APK, images in the transcript
Two strands land together: the window draws pictures now, and the whole front
end builds, installs and runs on the phone with the engine linked in-process.

Images (the "make a picture actually show up" part)
- Engine, additively: cst_agent_hooks_t.on_tool_images(call, list, n, ud) fires
  when a call left attachments behind, and cst_agent_pending_rel() /
  cst_agent_pending_is_image() read that list without the app touching the
  store. tests/test_attach.c::test_agent_pending pins the three cases (an
  image, a stored name that passes cst_attach_rel_ok, out-of-range NULL).
- App: image://cstattach/<stored name> (app/src/ui/SessionImages.cpp) serves
  only the current session's attachment directory, checked with
  cst_attach_rel_ok, decodes down to the requested sourceSize and never
  upscales, and holds a mutex because requestImage() runs on the render
  thread while the directory can change underneath it. AgentCore gained
  attachmentDir(), pendingImages(), the toolImages()/promptImages() signals
  and hookToolImages(); history() gives replayed rows their images too.
- QML: ImageStrip.qml (a thumb strip, <=150 px tall, shaped by the picture,
  tooltip with name/size/origin, the file's name when it went missing),
  wired into MessageCard and ToolCard. Qt 6's ListModel cannot hold a
  list-valued role (append() reads back undefined, setProperty drops it), so
  a row carries one image per line as <stored>|<name>|<origin>|<w>x<h> and
  ImageStrip decodes that.

Android (the engine is already in the APK; this is the front end)
- app/src/main.cpp: on Android the workspace, config, tmp and HOME go under
  the app's own data directory before the engine opens anything, because
  QDir::currentPath() is "/" there and /tmp is not writable.
- app/android/: the manifest (QtApplication/QtActivity, INTERNET and
  POST_NOTIFICATIONS, adjustResize, no storage permission -- the picker will
  be the system one), strings, a vector icon; minSdk 24 and targetSdk 35 come
  from app/CMakeLists.txt through QT_ANDROID_MIN/TARGET_SDK_VERSION.
- app/src/platform/android/AndroidTools.cpp: the Android file behind
  PlatformInit(), owner "app:android" (notification, clipboard, share sheet,
  file picker are stubs for now, so the tool list does not lie about them).
- app/tools/selftest.c decides its scratch workspace with os_temp_dir(), so
  the C smoke test runs on the device too.
- scripts/build_apk_android.sh: sources to installed APK in one command
  (environment checks, qt-cmake configure, --target apk, adb install), and
  the host-side plumbing the NixOS + qt.io combination needs -- the NDK
  sysroot include dirs for Qt's FindGLESv2/FindEGL (they only look under
  <sysroot>/include), -L$PREFIX/lib on the shared/module link lines as well
  as the executables', the Android debug keystore created on first use with
  QT_ANDROID_SIGN_APK + QT_ANDROID_KEYSTORE_* (androiddeployqt copies the
  unsigned gradle output and signs it itself), and the #!/bin/bash shebang of
  the build-tools wrappers, which NixOS does not have.
- scripts/gradle-android-init.gradle (installed into ~/.gradle/init.d/): the
  release lint task is switched off -- lintVitalAnalyzeRelease resolves its
  own tool jars straight from dl.google.com, bypassing every repository the
  build configures, and fails the build after all the real work is done.
- Icons: app/icons/*.svg (Breeze's 24@3x files, the 19 names the window asks
  for) are compiled in as :/cst-icons and ThemeIcons.cpp falls back to them
  when the platform has no icon theme. On the phone the Qt theme lookup found
  nothing and the QML glyph fallback was an empty box in the phone's fonts.
- docs/android.md: "The APK (Qt front end)" -- the SDK assembled without
  sdkmanager, the host Qt's missing libraries on NixOS, the Gradle mirrors,
  lint, signing -- and a Status that says what runs where. app/README.md
  gains the images and APK lines, and stops claiming Android platform tools
  that are still stubs.

Verified: engine `make check` green; desktop `cst-app-selftest: OK` and
--uitest all checks pass; phone (NX789J, Android 16, arm64-v8a) -- the APK
installs, starts on the workspace under /data/user/0/xyz.whcself.cstagent/
files/, a turn reaches a configured provider over HTTPS from inside the ART
process and renders the reply, rotation keeps the session and docks the
sidebar, and the toolbar/card icons draw.

Version 0.9.0.

mode = coding
2026-10-11 15:22:10 +08:00

169 lines
7.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build the Qt Quick Android APK of app/ (the engine is linked in-process, so
# the APK opens no port). This is the front end counterpart of
# scripts/build_android.sh, which cross-builds the engine alone.
#
# scripts/build_apk_android.sh [--install] [--logs] [--clean]
#
# --install adb install -r the debug APK on the connected device
# --logs stream logcat after installing (Qt + engine tags)
# --clean wipe the build directory first
#
# Needs, all overridable through the environment:
# QT_ANDROID Qt for Android (default ~/Qt/<ver>/android_arm64_v8a)
# QT_HOST matching host Qt (default ~/Qt/<ver>/gcc_64)
# ANDROID_SDK_ROOT SDK with platforms/ + build-tools/ (see docs/android.md)
# ANDROID_NDK_HOME NDK, same one as scripts/build_third_party_android.sh
# JAVA_HOME JDK 17+ for Gradle (falls back to the javac on PATH)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ABI="${ABI:-arm64-v8a}"
API_LEVEL="${API_LEVEL:-24}"
BUILD="$ROOT/build-apk-android"
PREFIX="$ROOT/third_party/prefix-android-$ABI"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
DO_INSTALL=0
DO_LOGS=0
for a in "$@"; do
case "$a" in
--install) DO_INSTALL=1 ;;
--logs) DO_INSTALL=1; DO_LOGS=1 ;;
--clean) rm -rf "$BUILD" ;;
*) echo "usage: $0 [--install] [--logs] [--clean]" >&2; exit 1 ;;
esac
done
# --- Qt: Android target first, then the host tools of the same version ----
if [[ -z "${QT_ANDROID:-}" ]]; then
# aqt names the directory after the ABI with underscores: android_arm64_v8a
for q in "$HOME"/Qt/*/android_"${ABI//-/_}"/lib/cmake/Qt6/qt.toolchain.cmake; do
[[ -f "$q" ]] && QT_ANDROID="${q%/lib/cmake/Qt6/qt.toolchain.cmake}"
done
fi
if [[ -z "${QT_ANDROID:-}" || ! -x "$QT_ANDROID/bin/qt-cmake" ]]; then
echo "set QT_ANDROID to a Qt for Android install ('aqt install-qt all_os android ...')" >&2
exit 1
fi
QT_VER="$(basename "$(dirname "$QT_ANDROID")")"
QT_HOST="${QT_HOST:-$(dirname "$QT_ANDROID")/gcc_64}"
if [[ ! -x "$QT_HOST/bin/qmake" && ! -x "$QT_HOST/bin/androiddeployqt" ]]; then
echo "set QT_HOST to the host Qt $QT_VER (same version, needed for androiddeployqt)" >&2
exit 1
fi
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
if [[ -z "$NDK" || ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then
echo "set ANDROID_NDK_HOME to an NDK (see scripts/build_third_party_android.sh)" >&2
exit 1
fi
if [[ -z "${ANDROID_SDK_ROOT:-}" || ! -d "$ANDROID_SDK_ROOT/platforms" ]]; then
echo "set ANDROID_SDK_ROOT to an SDK with platforms/android-<n> and build-tools/" >&2
exit 1
fi
if [[ -z "${JAVA_HOME:-}" ]]; then
JAVAC="$(command -v javac || true)"
[[ -n "$JAVAC" ]] && JAVA_HOME="$(dirname "$(dirname "$(readlink -f "$JAVAC")")")"
fi
if [[ -z "${JAVA_HOME:-}" || ! -x "$JAVA_HOME/bin/java" ]]; then
echo "set JAVA_HOME to a JDK 17+ (Gradle needs it)" >&2
exit 1
fi
if [[ ! -f "$PREFIX/lib/libcurl.a" ]]; then
echo "missing $PREFIX/lib/libcurl.a -- run:" >&2
echo " scripts/build_third_party_android.sh $ABI all" >&2
exit 1
fi
export ANDROID_SDK_ROOT ANDROID_NDK_HOME="$NDK" ANDROID_NDK_ROOT="$NDK" JAVA_HOME
export PATH="$JAVA_HOME/bin:$PATH"
# androiddeployqt shells out to the generated project's gradlew, which picks up
# init scripts from $GRADLE_USER_HOME/init.d. Install ours (release lint off)
# so every build behaves the same. See the file for why lint has to go.
GRADLE_INIT_DIR="${GRADLE_USER_HOME:-$HOME/.gradle}/init.d"
mkdir -p "$GRADLE_INIT_DIR"
if ! cmp -s "$ROOT/scripts/gradle-android-init.gradle" "$GRADLE_INIT_DIR/cst-android.gradle"; then
install -m644 "$ROOT/scripts/gradle-android-init.gradle" "$GRADLE_INIT_DIR/cst-android.gradle"
echo "installed $GRADLE_INIT_DIR/cst-android.gradle"
fi
# The release APK has to be signed to install. With CST_ANDROID_KEYSTORE the
# init script uses that keystore; otherwise it falls back to the standard
# Android debug key, created here on first use (same one Android Studio makes).
KEYSTORE="${CST_ANDROID_KEYSTORE:-$HOME/.android/debug.keystore}"
if [[ ! -f "$KEYSTORE" && -z "${CST_ANDROID_KEYSTORE:-}" ]]; then
mkdir -p "$(dirname "$KEYSTORE")"
keytool -genkeypair -keystore "$KEYSTORE" -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US" >/dev/null
chmod 600 "$KEYSTORE"
echo "created debug keystore: $KEYSTORE"
fi
# androiddeployqt reads these (the path/alias/passwords never reach the CMake
# command line, only the QT_ANDROID_SIGN_APK flag does).
export QT_ANDROID_KEYSTORE_PATH="$KEYSTORE"
export QT_ANDROID_KEYSTORE_ALIAS="${CST_ANDROID_KEY_ALIAS:-androiddebugkey}"
export QT_ANDROID_KEYSTORE_STORE_PASS="${CST_ANDROID_KEYSTORE_PASS:-android}"
export QT_ANDROID_KEYSTORE_KEY_PASS="${CST_ANDROID_KEY_PASS:-android}"
# NixOS has no /bin/bash, and build-tools' shell wrappers (apksigner is the one
# androiddeployqt calls, d8 and friends are the rest) start with #!/bin/bash.
# Point them at the bash that exists here; the SDK is a local copy, so this
# sticks.
BT_DIR="$(ls -d "$ANDROID_SDK_ROOT"/build-tools/* 2>/dev/null | sort -V | tail -1)"
BASH_BIN="$(command -v bash || echo /bin/sh)"
if [[ -n "$BT_DIR" ]]; then
while IFS= read -r f; do
sed -i "1s|^#!/bin/bash|#!$BASH_BIN|" "$f" && echo "fixed shebang: ${f#"$ANDROID_SDK_ROOT"/}"
done < <(grep -rls '^#!/bin/bash' "$BT_DIR" 2>/dev/null)
fi
# Qt6Gui wants GLESv2/EGL: the NDK sysroot has both, but Qt's Find modules
# only search CMAKE_FIND_ROOT_PATH prefixes (<sysroot>/include), while the
# headers live in <sysroot>/usr/include -- point them there directly.
NDK_SYSROOT_INC="$NDK/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include"
[[ -d "$NDK_SYSROOT_INC/GLES2" ]] || echo "warning: no GLES2 headers under $NDK_SYSROOT_INC" >&2
echo "Qt for Android : $QT_ANDROID ($QT_VER)"
echo "host Qt : $QT_HOST"
echo "SDK : $ANDROID_SDK_ROOT ($(ls "$ANDROID_SDK_ROOT/build-tools" | tr '\n' ' '))"
echo "NDK : $NDK"
echo "JDK : $JAVA_HOME"
echo "ABI : $ABI, minSdk $API_LEVEL"
# The engine comes from libcstcore.a, exactly as on the desktop: no TUI, no
# HTTP server (src/webui_stub.c stands in), no bundled busybox inside an APK.
"$QT_ANDROID/bin/qt-cmake" -S "$ROOT" -B "$BUILD" -G Ninja \
-DQT_HOST_PATH="$QT_HOST" \
-DANDROID_ABI="$ABI" -DANDROID_PLATFORM="android-$API_LEVEL" \
-DANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" -DANDROID_NDK="$NDK" \
-DGLESv2_INCLUDE_DIR="$NDK_SYSROOT_INC" -DEGL_INCLUDE_DIR="$NDK_SYSROOT_INC" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$PREFIX" \
-DCURL_INCLUDE_DIR="$PREFIX/include" -DCURL_LIBRARY="$PREFIX/lib/libcurl.a" \
-DCMAKE_EXE_LINKER_FLAGS="-L$PREFIX/lib" \
-DCMAKE_SHARED_LINKER_FLAGS="-L$PREFIX/lib" \
-DCMAKE_MODULE_LINKER_FLAGS="-L$PREFIX/lib" \
-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF -DCST_BUILD_APP=ON \
-DCST_BUNDLE_BUSYBOX=OFF
cmake --build "$BUILD" --target apk -j"$JOBS"
# AGP names the file after the variant and signing state
# (android-build-release.apk vs ...-unsigned.apk), so take the newest one.
APK="$(find "$BUILD/app/android-build/build/outputs/apk" "$BUILD" -name '*.apk' \
-printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)"
[[ -n "$APK" && -f "$APK" ]] || { echo "no APK produced" >&2; exit 1; }
echo
echo "APK: $APK ($(du -h "$APK" | cut -f1))"
[[ "$DO_INSTALL" == 1 ]] || exit 0
ADB="${ADB:-adb}"
command -v "$ADB" >/dev/null || { echo "adb not found (nix-shell -p android-tools)" >&2; exit 1; }
"$ADB" install -r "$APK"
echo "installed: $("$ADB" shell pm list packages | grep cstagent)"
[[ "$DO_LOGS" == 1 ]] || exit 0
echo "--- am start (Ctrl-C to stop; logcat follows)"
"$ADB" shell monkey -p xyz.whcself.cstagent -c android.intent.category.LAUNCHER 1 >/dev/null
sleep 2
exec "$ADB" logcat -s cst-agent:V Qt:V AndroidRuntime:E libc:V DEBUG:V