Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities). Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
CST-agent on Android
The engine runs on the phone as a plain bionic binary -- no Termux, no proot,
no second process for the UI to talk to. It is the same C core the CLI/TUI and
--web API use, cross-compiled with the NDK. The archived Qt front end
(archive/qt-app/) can link libcstcore.a in-process and register platform
tools under the owner app:android. Prefer a remote client on cst-webui/1
for new work.
Two scripts cover the engine side:
| script | what it does |
|---|---|
scripts/build_third_party_android.sh ABI [all|mbedtls|curl] |
static mbedTLS + libcurl into third_party/prefix-android-ABI |
scripts/build_android.sh [ABI] [--push] [--run] [--run-tests] |
libcstcore.a, cst-agent, the test binaries; optional push and on-device run |
Toolchain
NixOS: nixpkgs carries the NDK (the Android SDK license makes it unfree, hence the flag; the packaged NDK has a patched interpreter, so its clang runs here):
export ANDROID_NDK_HOME=$(NIXPKGS_ALLOW_UNFREE=1 nix build --impure --no-link \
--print-out-paths 'nixpkgs#androidenv.androidPkgs.ndk-bundle')/libexec/android-sdk/ndk-bundle
adb comes from nix-shell -p android-tools; the builds need cmake, ninja and
curl. End to end:
nix-shell -p cmake ninja curl android-tools --run '
JOBS=16 ./scripts/build_third_party_android.sh arm64-v8a all'
nix-shell -p cmake ninja curl android-tools --run '
JOBS=16 ./scripts/build_android.sh arm64-v8a --push --run'
--run finishes with the two smoke checks on the device (cst-agent --version and tests/test_embed, the in-process embedding contract);
--run-tests builds the whole tests/ suite, pushes it and runs every binary
on the phone, printing a pass/fail line per test.
The APK (Qt front end)
scripts/build_apk_android.sh [--install] [--logs] [--clean] configures with
the Qt for Android kit, builds the apk target (Gradle, no port opened) and
optionally installs it. Bits it needs, all overridable through the
environment:
| variable | what it points at |
|---|---|
QT_ANDROID |
Qt for Android 6.11.2 (aqt install-qt all_os android 6.11.2 android_arm64_v8a -O ~/Qt --archives qtbase qtdeclarative qtquickcontrols2) |
QT_HOST |
the host Qt of the same version (aqt install-qt linux desktop 6.11.2 linux_gcc_64 -O ~/Qt --archives qtbase qtdeclarative qtsvg) |
ANDROID_SDK_ROOT |
SDK with platforms/android-<n> and build-tools/<v> |
ANDROID_NDK_HOME |
the NDK the engine was built with |
JAVA_HOME |
JDK 17+ for Gradle |
The SDK without sdkmanager
nixpkgs's full androidsdk pulls the emulator and system images (gigabytes);
the two components actually needed are cheap. The platform comes from the nix
store (unfree, so the license flag is required) and build-tools from Google's
own zip, laid out as the SDK sdkmanager would:
PLATFORM=$(NIXPKGS_ALLOW_UNFREE=1 NIXPKGS_ACCEPT_ANDROID_SDK_LICENSE=1 nix build \
--impure --no-link --print-out-paths --expr \
'builtins.elemAt (import <nixpkgs> {}).androidenv.androidPkgs.platforms 2') # android-35
mkdir -p ~/android-sdk/{build-tools,platforms}
ln -s $PLATFORM/libexec/android-sdk/platforms/android-35 ~/android-sdk/platforms/
androiddeployqt then picks build-tools and platform-tools from there. For
build-tools, dl.google.com is the only source: from here it hands out
~100 KB/s per connection, so the script downloads the zip in 16 ranged
requests (≈25x faster) and fixes the exec bits Python's zipfile drops.
Host Qt on NixOS
The host Qt is built for a regular distribution: its tools (qmlimportscanner
first of all) look for libgssapi_krb5.so.2, libglib-2.0.so.0, libz.so.1,
libfontconfig, libX11, libEGL/libGL, libzstd, libbrotlidec,
libdbus-1, libfreetype, libstdc++, none of which exist in NixOS's search
paths. The tools carry RUNPATH=$ORIGIN/../lib, so symlinking those libraries
from the nix store into <QT_HOST>/lib/ is enough:
nix build --no-link --print-out-paths nixpkgs#krb5.lib nixpkgs#dbus.lib \
nixpkgs#libglvnd nixpkgs#fontconfig.lib nixpkgs#freetype nixpkgs#glib.lib \
nixpkgs#libx11 nixpkgs#libxkbcommon nixpkgs#zlib nixpkgs#zstd \
nixpkgs#brotli.lib nixpkgs#stdenv.cc.cc.lib # then ln -s <lib> ~/Qt/6.11.2/gcc_64/lib/
Two more things the script does on the way:
- GLESv2/EGL: Qt's
FindGLESv2.cmakeonly searchesCMAKE_FIND_ROOT_PATHprefixes (<sysroot>/include), but the NDK keeps the headers in<sysroot>/usr/include-- without-DGLESv2_INCLUDE_DIR/-DEGL_INCLUDE_DIRfind_package(Qt6 Gui)fails with "dependency GLESv2 could not be found". -L$PREFIX/libon every link type: the APK's payload is a shared module, soCMAKE_SHARED_LINKER_FLAGS/CMAKE_MODULE_LINKER_FLAGSneed the prefix too, not just the EXE flags.
Gradle
The generated project resolves plugins and dependencies from google() and
mavenCentral(), plus a Gradle distribution from services.gradle.org. From
here all three are slow (dl.google.com ~100 KB/s), so two local tweaks:
~/.gradle/init.d/*.gradle(machine-local, not in the repo) prepends the Aliyun/Tencent mirrors forpluginManagement,dependencyResolutionManagementand everybuildscript.- the wrapper in
<QT_ANDROID>/src/3rdparty/gradle/gradle/wrapper/points at the Tencent Gradle mirror (51 MB/s vs 100 KB/s).
scripts/gradle-android-init.gradle (installed into ~/.gradle/init.d/ by the
build script) switches off release lint: 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.
Signing
androiddeployqt copies the unsigned Gradle output and signs it itself
(QT_ANDROID_SIGN_APK + the QT_ANDROID_KEYSTORE_* variables; a signed Gradle
output makes it fail with "Could not copy unsigned APK"). The script exports
those variables and creates the standard Android debug keystore
(~/.android/debug.keystore, alias androiddebugkey) on first use;
CST_ANDROID_KEYSTORE and CST_ANDROID_KEY_ALIAS/_PASS override it.
apksigner is a bash script with a #!/bin/bash shebang, which NixOS does not
have, so the script rewrites the shebang of the build-tools wrappers.
What is Android-specific
- API level 24 (
API_LEVEL=24): bionic provides threads,execand/system/etc/security/cacerts, which is where libcurl is pointed (CURL_CA_PATH). Bump the level if a newer syscall or CA layout is needed. - No TUI, no WebUI:
-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF-- there is no ncurses on Android, and the phone front end talks to the engine in-process instead of over HTTP.src/webui_stub.ccompiles in place of the server. - Sandbox:
src/sandbox_exec.conly enables the seccomp/Landlock paths underdefined(__linux__) && !defined(__ANDROID__), so the phone build gets the plain exec path -- the same rule the Termux build follows. The seccomp supervisor used to compile in and the probe reported "seccomp", but on the device a write outside the workspace was not denied (tests/test_sandbox.c:308), so the phone build now reports no sandbox instead of one that does not hold. - sashidown is built per ABI (
vendor/sashidown/libsashidown-<abi>.a) with the NDK'saarch64-linux-android24-clangwrapper: CMake's compiler is plain clang and keeps--target/--sysrootin its own variables, which the vendored makefile cannot see. The host archive stays host-built. - Static libcurl needs
-L$PREFIX/libon the link line, because curl's own CMake config lists its mbedTLS dependencies as-lflags rather than paths.
Status
- arm64-v8a, Android 16 (NX789J):
cst-agentruns,tests/test_embedpasses. - The whole suite runs on the device (
scripts/build_android.sh arm64-v8a --run-tests): 36/36 after the diff test learned to tell toybox'sdifffrom GNU diffutils. - The APK builds, installs and runs: the Qt front end links the engine
in-process (no port),
main.cppputs the workspace, config and tmp under/data/user/0/xyz.whcself.cstagent/files/, and a turn does a real HTTPS request from inside the ART process. The saved provider key is resolved into the engine's config on every start (AgentCore::open(): saved key, the provider's environment variable,CST_API_KEY), so the phone answers without being asked for the key again. Rotation keeps the session; the sidebar appears in landscape. - Platform tools behind
app:android(archive/qt-app/src/platform/android/AndroidTools.cpp):notification_send,clipboard_get/clipboard_setunder the engine's own tool names andshare(ACTION_SEND+ chooser) all work on the device. The file card's Open builds acontent://URI through the app's FileProvider (<package>.qtprovider, Qt's@xml/qtprovider_paths), checksresolveActivity()before startingACTION_VIEWand grants the reader permission; Share reuses the share sheet. - Java helpers: a notification built entirely through
QJniObjectposted nothing on the NX789J --Notification.Builder.build()came back null and the tool could only say "failed: no exception detail", because Qt clears a Java exception before C++ gets to look at it. The one step that has to fail visibly (a builder, aPendingIntent,build(),notify()) now lives inarchive/qt-app/android/src/xyz/whcself/cstagent/Notifier.java, which catches the throwable and hands the text back for the tool result; the device then reportednotification sentand the shade had it. androiddeployqt copiesarchive/qt-app/android/with the rest of the package source and Qt's Gradle template puts itssrc/onjava.srcDirs, so a class dropped in that directory needs no build-script change -- the front service of the next step goes there too. - A session's transcript replays a delivered file (
deliver_file) as a card with Open and Share -- the copy lives next to the session, markedfor_userso no request ever contains it (seearchive/qt-archive/qt-app/README.md, "Files the agent delivers"). - A turn survives the app leaving the screen:
AgentCore::send()callsapp::holdForeground(), which startsCstService(archive/qt-app/android/src/xyz/whcself/cstagent/CstService.java) as a foreground service and takes a partial wake lock;finishTurn()stops it again. On the NX789J: with the app sent home and the screen off, the service showedisForeground=truewith itsCST-agent is workingnotification (id 2, the samecst-agentchannel), the lockcst-agent:turnwas acquired at the start of the turn and released at its end,run_shell sleep 60finished with the screen dark, and the service was gone once the turn was over.CST_APP_NO_SERVICE=1keeps a test run from touching the phone. - A turn that asked and got no answer: a consent question cannot outlive
the app being off screen.
AgentCore::hookPermission()/hookAskUser()refuse (or drop the question) at once while the app is in the background -- Qt reports that asQt::ApplicationSuspended, so the worker is never parked on a dialog nobody can see, and the transcript carries a line saying why. In the foreground the same two hooks wait two minutes (a permission) or five (a question) and then answer "no" with a reason instead of waiting for good. The GUI hop a platform tool makes (onGuiThread()inarchive/qt-app/src/platform/android/AndroidTools.cpp) pings first and gives up after three seconds, and the header shows what the turn is doing (shell: make -j8) so a long turn is not mistaken for a stuck one.--uitestcovers the window side of all this. - Documents:
md_to_pdf(the window's own tool,archive/qt-app/src/core/PdfTools.cpp, ownerapp:qt) lays markdown or a workspace.md/.htmlout as an A4 PDF with 15 mm margins and hands it over as a file card, exactly the waydeliver_filedoes;QPrinter/PrintSupport is not used, so the phone gets it too. Qt's PDF engine embeds a font subset, which is why a Chinese page is a few KB rather than the few MB a whole CJK font would cost (seearchive/qt-archive/qt-app/README.md). - Still to come: the back gesture, and the icon glyphs --
ThemeIconfinds no icon files on the phone and draws empty boxes where the desktop shows Breeze icons.