A phone freezes an app it cannot see, and the front end had nothing holding it: a turn that took minutes -- a long shell command, a provider that thinks, a build -- stopped quietly the moment the user pressed home, and the answer was only there if the window happened to come back. The engine's own /attach flow made this visible: an APK turn survived, a pocket did not. - app/android/src/xyz/whcself/cstagent/CstService.java: a foreground service started with an explicit component (nothing outside the app can reach it, no intent filter) for as long as a turn runs. Being a foreground service makes the process one Android neither freezes nor kills; a partial wake lock (30 min ceiling) keeps the CPU awake with the screen off. Its ongoing notification says what is running -- "CST-agent is working" plus the first line of the prompt -- on the same cst-agent channel the notification_send tool uses, and taps back into the app. A phone that refuses the foreground start (Android 12+ does, from the background) loses the protection, not the turn: the failure is caught and the service stops itself. - Manifest: FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC (API 34+ wants the typed one) and WAKE_LOCK, plus the <service> with foregroundServiceType="dataSync". The first build of this failed with "Expected '>', but got ' '" -- a manifest comment cannot contain "--". - Front end: app::holdForeground(text) / app::releaseForeground() in ToolProvider.h, one call for every platform -- Android starts and stops the service through JNI, a desktop has nothing to hold (its window is the app). AgentCore::send() calls it when the turn starts (firstLine() of the prompt is what the notification says) and finishTurn() releases it, so the notification exists exactly as long as the work does. CST_APP_NO_SERVICE=1 keeps a test run from touching the phone. - On the NX789J, with the app sent home and the screen off: the service read back isForeground=true with its notification, the wake lock cst-agent:turn was taken at the start of the turn and dropped at its end, run_shell "sleep 60" finished with the screen dark, and the service was gone after. Desktop: build clean, --uitest all green, --selftest and cst-app-selftest OK (the hold is a no-op there, so nothing about the Qt side changed). Version 0.9.4.
203 lines
11 KiB
Markdown
203 lines
11 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").
|
|
* **A turn survives the app leaving the screen**: `AgentCore::send()` calls
|
|
`app::holdForeground()`, which starts `CstService`
|
|
(`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.
|
|
* 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.
|