#!/bin/bash # Drives the Android app in the emulator with adb, for the scripted cases and for an agent on the runner. # Text and positions come from uiautomator's accessibility tree; coordinates are screen pixels, as in the screenshots. # Screenshots, trees and a log of every command (steps.log) go to UI_OUT, and logcat to UI_OUT/app.log. # # ui start [=...] # install the app, give a debug build the settings (OneHandUI, NetworkSMPProxyMode, # DeveloperTools, LogLevel), and start it with logcat # ui shot save UI_OUT/NN-.png, NN-.xml (the tree) and NN-.txt, and print the text on screen # ui text print the text on screen, one element per line after its centre x,y # ui tree print the elements that have a text or can be used: class, text, bounds and states # ui find print "x y left top right bottom", the element's centre and bounds; fail if it is not on screen # ui wait [secs] wait up to secs (30) until the text is on screen # ui click tap at x,y # ui click-text [secs] wait up to secs (20) for the text and tap its centre # ui scroll [times] # swipe at x,y to scroll towards the top (up) or bottom (down) of the content, 3 times unless given # ui type type ASCII text into the focused field, waiting up to 10 s for a field to have the focus; # fail unless the field then shows the text # ui key ... press keys by their Android names without KEYCODE_ (BACK, ENTER, DEL) # ui layout restart the app with the reachable toolbar (the one-hand UI, the default) or the top bar # ui relaunch restart the app and wait for the chat list # ui text-size set the system font scale: 1 is the default, 1.3 and 2 are larger # ui do [args] -- [args] ... # run the commands in order, stopping at the first that fails # ui stop stop the app and logcat # # Text is compared ignoring case, spaces and punctuation; an element that is exactly the text wins over one that contains it. set -euo pipefail # an agent on the runner runs this, and nothing it starts needs the agent's credentials unset CLAUDE_CODE_OAUTH_TOKEN : "${UI_OUT:?UI_OUT must be the output directory}" # Claude Code's Bash tool builds its own PATH, without the SDK that install-sdk.sh put on the job's PATH=${ANDROID_HOME:?ANDROID_HOME must be the SDK from install-sdk.sh}/platform-tools:$PATH package=chat.simplex.app prefs_file=$package.SIMPLEX_APP_PREFS.xml # the button that opens New chat, in the toolbar or floating, whenever the chat list shows chat_list_button="Start new chat" state=${UI_STATE:-${RUNNER_TEMP:-/tmp}/ui} here=$(cd "$(dirname "$0")" && pwd) mkdir -p "$UI_OUT" "$state" log() { echo "$(date -u +%H:%M:%S) $*" | tee -a "$UI_OUT/steps.log" } number() { if ! [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then echo "error: \"$1\" is not a number" exit 1 fi } # uiautomator fails while the screen is changing ("could not get idle state"), so a dump is retried dump() { local attempt for attempt in 1 2 3 4 5; do adb shell rm -f /sdcard/ui.xml if adb shell uiautomator dump /sdcard/ui.xml > /dev/null 2>&1 && adb exec-out cat /sdcard/ui.xml > "$state/ui.xml" && grep -q '&2 return 1 } screen() { dump python3 "$here/screen-tree.py" "$state/ui.xml" "$@" } wait_for() { local text=$1 deadline=$((SECONDS + $2)) until screen find "$text" 2> /dev/null; do [ "$SECONDS" -lt "$deadline" ] || return 1 sleep 1 done } next_file() { local n n=$(find "$UI_OUT" -maxdepth 1 -name '[0-9][0-9]-*.png' | wc -l) printf '%s/%02d-%s' "$UI_OUT" $((n + 1)) "$1" } screenshot() { local dir=$state/screenshot png rm -rf "$dir" mkdir -p "$dir" # without a window the emulator composes the screen on the host, where the guest's screencap can't read it adb emu screenrecord screenshot "$dir" > /dev/null for _ in $(seq 20); do png=$(find "$dir" -name '*.png' -size +0 | head -n 1) if [ -n "$png" ]; then mv "$png" "$1" return 0 fi sleep 0.5 done echo "error: no screenshot from the emulator" return 1 } shot() { local f if ! [[ "$1" =~ ^[A-Za-z0-9_-]+$ ]]; then echo "error: a shot name has only letters, digits, - and _" return 1 fi f=$(next_file "$1") screenshot "$f.png" dump cp "$state/ui.xml" "$f.xml" python3 "$here/screen-tree.py" "$f.xml" lines > "$f.txt" echo "::group::text on screen: $(basename "$f")" cat "$f.txt" echo "::endgroup::" log "shot $(basename "$f").png" } launch() { local activity activity=$(adb shell cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER "$package" | tail -n 1) adb shell am start -W -n "$activity" } # A debug build lets run-as rewrite its preferences while the app is stopped; the file is read back to check the write. set_prefs() { adb shell am force-stop "$package" adb shell "run-as $package cat shared_prefs/$prefs_file 2> /dev/null" > "$state/prefs.xml" || true python3 "$here/prefs.py" "$state/prefs.xml" "$@" > "$state/prefs-new.xml" adb shell "run-as $package sh -c 'mkdir -p shared_prefs && cat > shared_prefs/$prefs_file'" < "$state/prefs-new.xml" if ! adb shell "run-as $package cat shared_prefs/$prefs_file" | cmp -s - "$state/prefs-new.xml"; then echo "error: the app's preferences are not what was written" return 1 fi } start() { local apk=$1 started=$SECONDS shift adb install -r -t -g "$apk" log "installed in $((SECONDS - started)) s" if [ $# -gt 0 ]; then # a release build isn't debuggable, so run-as can't reach its preferences if adb shell run-as "$package" true 2> /dev/null; then set_prefs "$@" else log "start: $package is not debuggable, so it keeps its default settings, not $*" fi fi adb logcat -c setsid adb logcat -v threadtime < /dev/null >> "$UI_OUT/app.log" 2>&1 & echo $! > "$state/logcat.pid" launch } relaunch() { adb shell am force-stop "$package" launch if ! wait_for "$chat_list_button" 90 > /dev/null; then echo "error: no chat list 90 s after the restart" return 1 fi } layout() { local one_hand case "$1" in reachable) one_hand=true ;; top) one_hand=false ;; *) echo "error: layout is reachable or top" return 1 ;; esac set_prefs "OneHandUI=$one_hand" relaunch } # The app, slowed by the ARM translation, drops characters typed faster than it takes them, so the text is typed a few # characters at a time, each part checked on the field. Each part goes to the device's shell as one single-quoted word, # so neither shell interprets it; input text reads %s as a space. type_text() { local text=$1 typed="" part deadline=$((SECONDS + 10)) until screen focused; do if [ "$SECONDS" -ge "$deadline" ]; then echo "error: no text field has the focus" return 1 fi sleep 1 done while [ -n "$text" ]; do part=${text:0:8} text=${text:8} typed+=$part part=${part// /%s} adb shell "input text '${part//\'/\'\\\'\'}'" deadline=$((SECONDS + 10)) until screen focused "$typed"; do if [ "$SECONDS" -ge "$deadline" ]; then echo "error: the field doesn't show \"$typed\"" return 1 fi sleep 1 done done } scroll() { local x=$1 y=$2 times=$4 to i number "$x" number "$y" number "$times" case "$3" in up) to=$((y + 600)) ;; down) to=$((y - 600)) ;; *) echo "error: scroll up or down, not \"$3\"" return 1 ;; esac for ((i = 0; i < times; i++)); do adb shell input swipe "$x" "$y" "$x" "$to" 300 done } key() { local k for k in "$@"; do if ! [[ "$k" =~ ^[A-Z0-9_]+$ ]]; then echo "error: \"$k\" is not a key name such as BACK or ENTER" return 1 fi adb shell input keyevent "KEYCODE_$k" done } stop() { adb shell am force-stop "$package" if [ -f "$state/logcat.pid" ]; then kill "$(cat "$state/logcat.pid")" 2> /dev/null || true rm -f "$state/logcat.pid" fi } case "${1:-}" in start) shift log "start $*" start "$@" ;; shot) shot "$2" ;; text) screen lines ;; tree) screen tree ;; find) if ! pos=$(screen find "$2"); then log "find \"$2\": not on screen" >&2 exit 1 fi log "find \"$2\": $pos" > /dev/null echo "$pos" ;; wait) secs=${3:-30} number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "wait \"$2\": not on screen after $secs s" exit 1 fi read -r x y _ <<< "$pos" log "wait \"$2\": at $x,$y" ;; click) number "$2" number "$3" adb shell input tap "$2" "$3" log "click $2,$3" ;; click-text) secs=${3:-20} number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "click-text \"$2\": not on screen after $secs s" exit 1 fi read -r x y _ <<< "$pos" adb shell input tap "$x" "$y" log "click-text \"$2\" at $x,$y" ;; scroll) scroll "$2" "$3" "$4" "${5:-3}" log "scroll $4 at $2,$3, ${5:-3} times" ;; type) type_text "$2" log "type \"$2\"" ;; key) shift key "$@" log "key $*" ;; layout) layout "$2" log "layout $2" ;; relaunch) relaunch log "relaunch" ;; text-size) number "$2" adb shell settings put system font_scale "$2" log "text-size $2" ;; do) shift command=() for arg in "$@" --; do if [ "$arg" != -- ]; then command+=("$arg") elif [ ${#command[@]} -gt 0 ]; then "$0" "${command[@]}" command=() fi done ;; stop) stop log "stop" ;; *) sed -n '3,/^$/s/^# \{0,1\}//p' "$0" exit 1 ;; esac