- apps/multiplatform/scripts/waydroid: wd.sh (headless Waydroid for the Android UI tests and manual GUI testing), provision2.sh and watchdog2.sh for a second instance, weston.ini, fetch-images.sh, README; works on Qubes AppVMs and other Linux hosts (apt/dnf install, firewalld, own nft table, weston 10-14 flags); - uitest-android.sh uses it by default (WD_SH overrides) and takes adb from it, so both use one adb; - uitest.sh usage header; - plans/ui-tests: test drafts (including unmerged batch 3), the 2026-10-14 review reports, results after the rebase.
12 KiB
wd.sh — headless Android (Waydroid) for GUI testing
A headless Waydroid container: LineageOS 20 (Android 13, x86_64) with libndk arm64 translation, so it runs the real
SimpleX debug APKs, which ship arm64-v8a native libraries only. One script, wd.sh, drives everything. It works
from a fresh shell with no environment set.
scripts/uitest-android.sh uses it to run the Android UI tests (plans/ui-tests/). To run the tests on another
device or emulator instead, set UITEST_SERIAL and Waydroid is not used.
Why Waydroid and not the Android emulator
- On hosts without
/dev/kvm(a Qubes AppVM, most CI VMs), the emulator runs only x86_64 AVDs under QEMU TCG, with no ARM translation. An x86_64 image boots in about 150 s, and a 266 MB APK install did not finish in 16 min. - Waydroid is an LXC container. Android runs natively on the host CPU and only the app's arm64
.sofiles are translated. Android boots in 37–56 s, an APK installs in 5–8 s, and the app cold-starts in 2–3 s.
Prerequisites
- Linux with systemd, a kernel with
CONFIG_ANDROID_BINDER_IPC=yandCONFIG_ANDROID_BINDERFS=y(or the binder modules), nftables, and passwordlesssudo(or runsudo -vfirst: test runs callwd.shin the background). - Packages:
waydroid,weston(withweston-screenshooter),dnsmasq;python3-venvandgitfor the arm translation; optionalpython3-pil(shot --half/--crop) andunzip.wd.sh setupinstalls the first three withapt-get(Debian/Ubuntu, from repo.waydro.id) ordnf(Fedora); on other distributions install them yourself. - Android SDK platform-tools.
adbis taken from$ADB,~/Android/Sdk/platform-tools,$ANDROID_HOME,$ANDROID_SDK_ROOT, thenPATH(wd.sh adb-pathprints the one used). - Weston 10 to 14 work:
wd.shpicks the pixman and headless-backend flags by version.
Files
The scripts live here; the state lives outside the repo, in WD_HOME (default ~/waydroid) and WD2_HOME
(default ~/waydroid2, instance 2).
| Path | What |
|---|---|
wd.sh |
the tool |
weston.ini |
weston config: no panel, no locking ($WD_HOME/weston.ini overrides it) |
fetch-images.sh, repair-zip.py |
download the system/vendor images into $WD_HOME/images (parallel, resumable) |
provision2.sh |
build instance 2 in $WD2_HOME from the installed Waydroid and instance 1's state |
watchdog2.sh [secs] |
kill instance 2 if instance 1 loses its display service (use while changing instance 2's setup) |
$WD_HOME/images/ |
optional local images; without them waydroid init downloads them (Qubes requires them, below) |
$WD_HOME/var/ |
optional: when present (always on Qubes), /var/lib/waydroid is a bind mount of it |
$WD_HOME/debs/ |
optional: .deb files for an offline package install (Qubes) |
$WD_HOME/waydroid_script/, venv/, translation/ |
the arm translation installer (cloned on first setup) and cached zips |
$WD_HOME/run/ |
weston/session logs and pids; current = the last command that took the lock |
~/.local/share/waydroid/ |
instance 1's Android /data (Waydroid's default) |
Qubes OS
A Qubes AppVM resets its root FS (/usr, /etc, /var) on every restart. wd.sh detects Qubes and keeps
everything in $WD_HOME:
- packages are reinstalled from
$WD_HOME/debs(offline) when missing; /var/lib/waydroidis a bind mount of$WD_HOME/var, and/etc/waydroid-extra/imageslinks to$WD_HOME/images(download them once withfetch-images.sh);- Qubes' firewall drops input from unknown interfaces, which breaks DHCP and DNS for the container:
setup/upaddnft add rule ip qubes custom-input iifname "waydroid0" accept(not persistent; re-added byup).
wd.sh up notices a reset root FS and runs setup itself (offline, about a minute). Android's data survives restarts.
Other hosts
/var/lib/waydroidis used directly (unless$WD_HOME/varexists), andwaydroid initdownloads the images if$WD_HOME/imageshas none.- Firewall: with firewalld running,
setup/upadd the bridge to thetrustedzone (runtime only). With ufw active they only print the commands to allow it. Other firewalls must accept DHCP/DNS fromwaydroid0(andwaydroid1). net isolateadds a drop rule to an nft table of its own (inet wd_uitest);net openremoves it.
Quick start
W=scripts/waydroid/wd.sh # from apps/multiplatform
$W who # free? (shared device; or use instance 2: WD_INSTANCE=2)
$W claim <you>; export WD_OWNER=<you> # claim before changing anything; <you> = a name only you use
$W up # setup if needed, then weston + Android; prints the adb serial
$W install android-arm64-v8a-debug.apk
$W reset-app chat.simplex.app.debug # pm clear = fresh app data
$W launch chat.simplex.app.debug
$W shot /tmp/s.png # PNG, 720x1440, same coordinates as dump/tap
$W nodes # compact list: tapX tapY flags class resource-id label
$W wait "Get started" 90 && $W tap-text "Get started"
$W release # leave it running; `down` frees its RAM
Run wd.sh help for all commands: setup up down status who claim release wait-free install uninstall launch stop-app reset-app wait gone rootsh packages shot dump nodes find tap tap-text tap-row swipe text key back home logcat shell bt adb adb-path serial reverse reverse-clear net.
Sharing between sessions (locking)
- Every command except the read-only ones and claim/release takes an exclusive
flockon the instance's.lockwhile it runs, so state-changing commands from two sessions never interleave. A command waits up to 600 s for the lock, then fails and names the holder. - For a whole test run, claim the instance:
wd.sh claim <name>, then passWD_OWNER=<name>on every call. While the claim is live, any other owner (or a call with no owner) fails with a message saying who holds it. The read-only commands (status who serial nodes dump shot find wait gone logcat packages wait-free adb-path) are always allowed and do not refresh the claim. - A claim expires after
WD_CLAIM_TTLseconds without use (default 1800).wd.sh whoshows the owner and its idle time,wd.sh wait-free [secs]blocks until the claim is free,wd.sh claim <name> --forcetakes it over (only when the owner is known to be gone), andwd.sh releaseends a claim.
Second instance (WD_INSTANCE=2)
Upstream Waydroid supports one container per host. Instance 2 is a patched copy of Waydroid's code in
$WD2_HOME/lib that renames every per-instance identifier:
- state
/var/lib/waydroid2(a bind of$WD2_HOME/var), LXC containerwaydroid2; - bridge
waydroid1192.168.241.0/24 (the device is192.168.241.113:5555), Wayland socketwayland-waydroid2; - private system and session D-Bus daemons (sockets in
$WD2_HOME/run), the transient unitwaydroid2-container; - its own binder devices
binder2/vndbinder2/hwbinder2in the same binderfs; - Android data in
$WD2_HOME/xdg; the system/vendor images are shared read-only. Its memory is capped at 2.5 GB.
scripts/waydroid/provision2.sh # once (instance 1 set up first), and after a Waydroid update
WD_INSTANCE=2 WD_OWNER=<you> scripts/waydroid/wd.sh claim <you>
WD_INSTANCE=2 WD_OWNER=<you> scripts/waydroid/wd.sh up
WD_INSTANCE=2 WD_OWNER=<you> scripts/waydroid/wd.sh down # frees its RAM (~1.5–2.5 GB)
upre-creates all runtime pieces after a reboot: mounts, binder nodes, D-Bus, firewall rule, unit. If instance 1 has not run since then, it starts instance 1's container service only to mount binderfs.- Binder guard:
uprefuses to boot instance 2 unless$WD2_HOME/var/lxc/waydroid2/config_nodesmaps/dev/binder2(etc.) and those devices differ from instance 1's. Two Androids on one binder context corrupt each other's service registry (instance 1 lost itsdisplayservice untilwd.sh down && wd.sh up). - The UI tests use instance 2 (
scripts/uitest-android.sh,WD_INSTANCE, default 2), so instance 1 stays free.
How an agent should drive the GUI
Loop: observe → act → re-observe.
wd.sh nodes(2–3 s) lists every labelled or clickable node with tap coordinates: the centre of the nearest clickable ancestor, because Compose puts the text on a non-clickable child. Flags:Cclickable,Ffocused,Sscrollable,Kchecked,Ppassword.wd.sh shot file.png(0.5 s) for anything visual. Pixel coordinates matchnodesandtap.- Act with
tap-text "<label>"(preferred) ortap x y,swipe,text "…"(focus the field first),key BACK|ENTER|…. - Wait with a poll, not a sleep:
wd.sh wait "Continue" 20,wd.sh gone TEXT [secs]. Both see only the top window.
Compose notes: test tags show up as resource-id only with testTagsAsResourceId = true (SimpleX does not set it),
so nodes are found by text or content description. The soft keyboard covers the lower half of the screen after typing,
and the first key BACK only hides it.
Networking
- Instance 1 is
192.168.240.112on bridgewaydroid0(host side192.168.240.1); instance 2 is onwaydroid1(192.168.241.x). adb runs over TCP (wd.sh serial). - Host servers listening on the bridge address or
0.0.0.0are reachable from Android at192.168.240.1:<port>. Servers listening only on127.0.0.1are reachable throughwd.sh reverse <device-port> <host-port>(adb reverse);wd.sh reverse-clearremoves them. - Internet: Android reaches it through the host's NAT.
wd.sh net isolatedrops forwarding from the bridge, so Android reaches only the host (bridge IP and adb reverse still work);wd.sh net openundoes it;wd.sh net statusshows it.
Known limitations
- No
adb screencap(empty file in Waydroid):wd.sh shotcaptures the weston output (weston runs with--debug). - Window needs desktop-shell: the Waydroid window is created with
waydroid show-full-ui; kiosk-shell never maps it. - Container freeze: Waydroid freezes the container when Android sleeps, and adb then hangs.
upsetspersist.waydroid.suspend=falseand keeps the screen on; every device command unfreezes first. - arm translation: libndk (default) or libhoudini (
WD_TRANSLATION=libhoudini); only libndk was tested. SetWD_ANDROID_VERSIONif the images are not Android 13. The Haskell core's first database creation takes ~45 s. - No GPU (SwiftShader) and no camera (tests that open the QR scanner are skipped on Waydroid).
- Two instances at most. Screen size is fixed per
up(WD_WIDTH/WD_HEIGHT, default 720x1440). - Tested on a Qubes OS Debian 12 AppVM (weston 10), including the own nft table (with the Qubes check switched off). The weston 12+ flags were checked against weston's sources. The apt/dnf install, image download and firewalld paths follow the upstream tools' documented behaviour but were not run on a non-Qubes host.
Troubleshooting
| Symptom | Fix |
|---|---|
Waydroid is not running or has no IP |
wd.sh up |
container has no IPv4 |
host firewall blocks DHCP from the bridge (see Qubes / Other hosts); dnsmasq must be installed |
Android did not boot + umount …/rootfs in run/session.log |
stale mount: wd.sh down && wd.sh up |
| adb commands hang | container frozen: waydroid status shows FROZEN; wd.sh unfreezes before device commands |
| black screenshot | weston started without --debug or with kiosk-shell: wd.sh down && wd.sh up |
INSTALL_FAILED_NO_MATCHING_ABIS |
translation missing: grep native.bridge /var/lib/waydroid/waydroid_base.prop; rerun wd.sh setup |
| a command waits for the lock | wd.sh who, cat $WD_HOME/run/current |
| logs | $WD_HOME/run/weston.log, run/session.log, sudo waydroid log, wd.sh logcat |
| instance 2 | WD_INSTANCE=2 wd.sh status, $WD2_HOME/run/*.log, /var/lib/waydroid2/waydroid.log, sudo journalctl -u waydroid2-container |
| images lost (Qubes) | fetch-images.sh, then wd.sh setup |
Never pkill -f wd.sh or pgrep -f … with a pattern that appears in your own command line: it kills your own shell.