Files
CST-agent/docs/android.md
T
WHC d4f65b17cf v0.9.3: notification_send posts on the phone, and Java helpers have a home
The tool was the last Android one that never worked on the NX789J: it would
fail with "Notification.Builder.build() failed: no exception detail" and the
shade stayed empty. The detail was the whole story -- Qt clears a Java
exception before C++ gets to look at it, so a build() that throws and a
build() that returns null look exactly the same from here, and the only way
to catch the difference is to let Java catch it.

- app/android/src/xyz/whcself/cstagent/Notifier.java: the builder, the
  PendingIntent, build() and notify() in one try/catch that returns "" or
  the throwable's class and message. androiddeployqt copies app/android/ with
  the rest of the package source and Qt's Gradle template puts its src/ on
  java.srcDirs, so a class dropped there needs no build-script change (the
  first one is this; the foreground service of the next step goes there too).
- AndroidTools.cpp keeps what it can see from C++ (the context, the channel
  on API 26+, the small icon looked up by name, the launch intent) and calls
  Notifier.post(); the result line now carries the Java failure when there is
  one. The javaErrorText() helper of the previous attempt is gone: reading a
  pending exception from C++ is exactly what does not work here.
- On the device: the tool answers "notification sent", the system holds the
  record (channel cst-agent, importance 3, the framework small icon, the tap
  intent back into the app). Earlier attempts in the same session are in the
  transcript next to it: four failures from the old path, then this one.

Docs: docs/android.md gets the Java-helper mechanism (and why the tool has to
be wrapped in Java at all) and no longer lists notification_send as missing;
app/README.md's platform table and the Android note say where a Java class
goes.

Version 0.9.3.
2026-10-11 16:40:51 +08:00

10 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, the TUI and the WebUI use, cross-compiled with the NDK; on the phone the Qt front end (app/) will link libcstcore.a in-process and register its own platform tools under the owner app:android.

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.cmake only searches CMAKE_FIND_ROOT_PATH prefixes (<sysroot>/include), but the NDK keeps the headers in <sysroot>/usr/include -- without -DGLESv2_INCLUDE_DIR/-DEGL_INCLUDE_DIR find_package(Qt6 Gui) fails with "dependency GLESv2 could not be found".
  • -L$PREFIX/lib on every link type: the APK's payload is a shared module, so CMAKE_SHARED_LINKER_FLAGS/CMAKE_MODULE_LINKER_FLAGS need 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 for pluginManagement, dependencyResolutionManagement and every buildscript.
  • 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, exec and /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.c compiles in place of the server.
  • Sandbox: src/sandbox_exec.c only enables the seccomp/Landlock paths under defined(__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's aarch64-linux-android24-clang wrapper: CMake's compiler is plain clang and keeps --target/--sysroot in its own variables, which the vendored makefile cannot see. The host archive stays host-built.
  • Static libcurl needs -L$PREFIX/lib on the link line, because curl's own CMake config lists its mbedTLS dependencies as -l flags rather than paths.

Status

  • arm64-v8a, Android 16 (NX789J): cst-agent runs, tests/test_embed passes.
  • 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's diff from GNU diffutils.
  • The APK builds, installs and runs: the Qt front end links the engine in-process (no port), main.cpp puts 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 (app/src/platform/android/AndroidTools.cpp): notification_send, clipboard_get / clipboard_set under the engine's own tool names and share (ACTION_SEND + chooser) all work on the device. The file card's Open builds a content:// URI through the app's FileProvider (<package>.qtprovider, Qt's @xml/qtprovider_paths), checks resolveActivity() before starting ACTION_VIEW and grants the reader permission; Share reuses the share sheet.
  • Java helpers: a notification built entirely through QJniObject posted 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, a PendingIntent, build(), notify()) now lives in app/android/src/xyz/whcself/cstagent/Notifier.java, which catches the throwable and hands the text back for the tool result; the device then reported notification sent and the shade had it. androiddeployqt copies app/android/ with the rest of the package source and Qt's Gradle template puts its src/ on java.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, marked for_user so no request ever contains it (see app/README.md, "Files the agent delivers").
  • Still to come: the SAF picker for attaching a picture from another app, the back gesture, and the icon glyphs -- ThemeIcon finds no icon files on the phone and draws empty boxes where the desktop shows Breeze icons.