Files
CST-agent/docs/android.md
T
WHC 60cbce3c5c v0.9.2: files the agent delivers come back as cards, and the saved key is used
A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.

deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
  the model; cst_attach_import_artifact() stores any bytes up to
  CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
  extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
  directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
  skips for_user entries without even a "not sent" note, and a turn that only
  delivered a file no longer invents an empty user message. tests/test_artifacts.c
  pins that down end to end, including a session-file round trip.

The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
  name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
  entry is a file card (extension badge, name, type and size, Open, Share);
  a picture stays a thumbnail and opens on tap. Both shapes are read, so
  sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
  the session's own messages carries it, and hand the display name to the
  platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
  resolveActivity() checked, read permission granted) and shares through the
  share sheet; a desktop uses QDesktopServices / the clipboard.
  CST_APP_NO_OPEN=1 makes --uitest run without opening anything.

Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
  `for_user`: the file survived the file, but came back as an attachment --
  no card, and put back in front of the model. It now uses the one writer
  (cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
  card that is still running -- and the engine reports the result *before*
  the files, so the card was already complete and the files were dropped.
  A live turn therefore showed no card while a replay of the same session
  did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
  never drawn. The card now carries the reason ("no app on the phone opens
  report.md").

Also
- The saved provider key reaches the engine on every start: AgentCore::open()
  was handed an empty key and the engine never resolves credentials itself
  (only the CLI did), so every start asked for it again. It resolves the same
  order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
  the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
  and keeps failing on the NX789J -- the build() returns null and Qt clears
  the exception before C++ can read it, so the call has to be wrapped in Java
  next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
  refused), the live order of result-then-files, and the startup row count.

Version 0.9.2.
2026-10-11 16:34:47 +08:00

185 lines
9.6 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`):
`clipboard_get` / `clipboard_set` under the engine's own tool names and
`share` (`ACTION_SEND` + chooser) both 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.
`notification_send` still fails on the NX789J: the channel and the small
icon are found, `Notification.Builder.build()` returns null, and Qt clears
the Java exception before C++ can read it ("no exception detail"). Catching
it needs the call to be wrapped in Java (a small `app/android/src/...`
helper class), which is the next step there.
* 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.