Files
simplex-chat/scripts/ci/android/ui

342 lines
10 KiB
Bash
Executable File

#!/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 <apk> [<setting>=<value>...]
# install the app, give a debug build the settings (OneHandUI, NetworkSMPProxyMode,
# DeveloperTools, LogLevel), and start it with logcat
# ui shot <name> save UI_OUT/NN-<name>.png, NN-<name>.xml (the tree) and NN-<name>.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 <text> print "x y left top right bottom", the element's centre and bounds; fail if it is not on screen
# ui wait <text> [secs] wait up to secs (30) until the text is on screen
# ui click <x> <y> tap at x,y
# ui click-text <text> [secs] wait up to secs (20) for the text and tap its centre
# ui scroll <x> <y> <up|down> [times]
# swipe at x,y to scroll towards the top (up) or bottom (down) of the content, 3 times unless given
# ui type <text> 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 <key>... press keys by their Android names without KEYCODE_ (BACK, ENTER, DEL)
# ui layout <reachable|top> 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 <scale> set the system font scale: 1 is the default, 1.3 and 2 are larger
# ui do <command> [args] -- <command> [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 '<hierarchy' "$state/ui.xml"; then
return 0
fi
sleep 1
done
echo "error: uiautomator could not dump the screen" >&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