#!/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
