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>
223 lines
12 KiB
Markdown
223 lines
12 KiB
Markdown
# 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):
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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` (`archive/qt-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
|
|
`archive/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
|
|
reported `notification sent` and the shade had it. androiddeployqt copies
|
|
`archive/qt-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 `archive/qt-archive/qt-app/README.md`, "Files the agent
|
|
delivers").
|
|
* **A turn survives the app leaving the screen**: `AgentCore::send()` calls
|
|
`app::holdForeground()`, which starts `CstService`
|
|
(`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 showed
|
|
`isForeground=true` with its `CST-agent is working` notification (id 2, the
|
|
same `cst-agent` channel), the lock `cst-agent:turn` was acquired at the
|
|
start of the turn and released at its end, `run_shell sleep 60` finished with
|
|
the screen dark, and the service was gone once the turn was over.
|
|
`CST_APP_NO_SERVICE=1` keeps 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 as `Qt::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()` in
|
|
`archive/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.
|
|
`--uitest` covers the window side of all this.
|
|
* **Documents**: `md_to_pdf` (the window's own tool,
|
|
`archive/qt-app/src/core/PdfTools.cpp`, owner `app:qt`) lays markdown or a workspace
|
|
`.md`/`.html` out as an A4 PDF with 15 mm margins and hands it over as a file
|
|
card, exactly the way `deliver_file` does; `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 (see `archive/qt-archive/qt-app/README.md`).
|
|
* Still to come: 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.
|