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

193 lines
10 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, 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):
```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` (`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.