Add 'fs5 ui' subcommand with 4 operations: - ui dump [path] — dump UI hierarchy XML via uiautomator - ui elements [--json] — list all interactive elements with coords - ui find <text> — find element by text/content-desc, return x y - ui tap <text> — find element and tap it (ui find + tap) lib/input.sh: - adb_ui_dump() — uiautomator dump → pull XML → cleanup - adb_ui_find() — Python XML parse, case-insensitive search - adb_ui_elements() — full element inventory as JSON tests/test_ui.bats: 22 new tests (fixture-based, no live device needed) Total: 85/85 BATS tests passing (was 64/64) Research findings (AppAgent pattern, ADB+vision): - UIAutomator dump: best no-root UI inspection on Android 9 SDK 28 - AppAgent (6.6k★): screenshot + GPT-4V + ADB tap/swipe pipeline - RustDesk InputService already enabled → accessibility layer available - scrcpy 3.3.4 + tesseract OCR pipeline already working
1090 lines
30 KiB
Bash
Executable File
1090 lines
30 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# fs5 — Android FS5 device management CLI
|
|
# Device: exone GmbH FS5 (Android 9)
|
|
# Usage: ./fs5 <command> [options]
|
|
set -uo pipefail
|
|
# Note: -e intentionally omitted; we handle errors explicitly for correct exit codes
|
|
|
|
# ── Resolve script directory ──────────────────────────────────────────────────
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
# ── Load config from .env (environment variables take precedence) ─────────────
|
|
ENV_FILE="${SCRIPT_DIR}/.env"
|
|
if [[ -f "${ENV_FILE}" ]]; then
|
|
# Load only variables not already set in the environment
|
|
while IFS='=' read -r key value; do
|
|
# Skip comments and empty lines
|
|
[[ "${key}" =~ ^[[:space:]]*# ]] && continue
|
|
[[ -z "${key}" ]] && continue
|
|
key="${key// /}"
|
|
# Only set if not already exported in environment
|
|
if [[ -z "${!key+x}" ]]; then
|
|
export "${key}=${value}"
|
|
fi
|
|
done < "${ENV_FILE}"
|
|
fi
|
|
|
|
# Defaults (fallback if neither env nor .env provided)
|
|
DEVICE_SERIAL="${DEVICE_SERIAL:-RD51QE202392}"
|
|
LOG_FILE="${LOG_FILE:-${SCRIPT_DIR}/fs5.log}"
|
|
ADB_TIMEOUT="${ADB_TIMEOUT:-10}"
|
|
|
|
# ── Load libraries ────────────────────────────────────────────────────────────
|
|
# shellcheck source=lib/output.sh
|
|
source "${SCRIPT_DIR}/lib/output.sh"
|
|
# shellcheck source=lib/adb.sh
|
|
source "${SCRIPT_DIR}/lib/adb.sh"
|
|
# shellcheck source=lib/input.sh
|
|
source "${SCRIPT_DIR}/lib/input.sh"
|
|
|
|
# ── Global flags ──────────────────────────────────────────────────────────────
|
|
JSON_OUTPUT=false
|
|
FORCE=false
|
|
|
|
# ── Usage ─────────────────────────────────────────────────────────────────────
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: fs5 <command> [options]
|
|
|
|
Device Management:
|
|
status Show device health and info
|
|
push <src> <dst> Push file/dir to device
|
|
pull <src> <dst> Pull file/dir from device
|
|
app list List installed apps
|
|
app install <apk> Install APK on device
|
|
app uninstall <pkg> Uninstall package from device
|
|
shell [cmd] Execute shell command on device
|
|
logs Stream or capture logcat output
|
|
|
|
Remote Control (AI capabilities):
|
|
screen Launch scrcpy GUI mirror with input
|
|
screenshot [path] Capture screen to PNG file
|
|
tap <x> <y> Send tap at coordinates
|
|
swipe <x1> <y1> <x2> <y2> [ms] Send swipe gesture
|
|
type <text> Type text into focused field
|
|
key <keycode> Send Android key event
|
|
launch <package> Launch app by package name
|
|
ocr Screenshot + extract all text via OCR
|
|
ui dump [path] Dump UI hierarchy XML (UIAutomator)
|
|
ui elements List all interactive UI elements
|
|
ui find <text> Find element by text, print coordinates
|
|
ui tap <text> Find element by text and tap it
|
|
sms list List recent SMS messages
|
|
sms otp Extract latest OTP code from SMS
|
|
sms watch Watch for new SMS messages
|
|
|
|
Global Options:
|
|
--json Output as JSON
|
|
--force Allow destructive/overwrite operations
|
|
--help Show this help
|
|
|
|
Exit Codes:
|
|
0 Success
|
|
1 General error
|
|
2 Device not found
|
|
|
|
Device: ${DEVICE_SERIAL}
|
|
EOF
|
|
}
|
|
|
|
# ── Parse global flags (modifies globals, returns remaining args via array) ───
|
|
# Usage: parse_global_flags REMAINING_ARRAY_NAME "$@"
|
|
parse_global_flags() {
|
|
local -n _remaining_ref="${1}"
|
|
shift
|
|
_remaining_ref=()
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--json) JSON_OUTPUT=true ;;
|
|
--force) FORCE=true ;;
|
|
--help|-h) usage; exit 0 ;;
|
|
*) _remaining_ref+=("${arg}") ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ── Command: status ───────────────────────────────────────────────────────────
|
|
cmd_status() {
|
|
local show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--json) JSON_OUTPUT=true ;;
|
|
--help) show_help=true ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 status [--json]
|
|
|
|
Show device health information including model, Android version,
|
|
battery level, storage usage, and ADB connection state.
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local model android_version battery_level battery_charging
|
|
local storage_total storage_used storage_free storage_pct adb_state ts
|
|
|
|
model=$(adb_get_prop "ro.product.model")
|
|
android_version=$(adb_get_prop "ro.build.version.release")
|
|
battery_level=$(adb_battery_level)
|
|
battery_charging=$(adb_battery_charging)
|
|
adb_state=$(adb -s "${DEVICE_SERIAL}" get-state 2>/dev/null || echo "unknown")
|
|
ts=$(date -Iseconds)
|
|
|
|
# Storage: total used free pct
|
|
read -r storage_total storage_used storage_free storage_pct \
|
|
<<< "$(adb_storage_info)"
|
|
|
|
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
|
out_json \
|
|
"serial=${DEVICE_SERIAL}" \
|
|
"model=${model}" \
|
|
"manufacturer=exone GmbH" \
|
|
"android_version=${android_version}" \
|
|
"battery_level=${battery_level}" \
|
|
"battery_charging=${battery_charging}" \
|
|
"storage_total_kb=${storage_total}" \
|
|
"storage_used_kb=${storage_used}" \
|
|
"storage_free_kb=${storage_free}" \
|
|
"storage_percent_used=${storage_pct}" \
|
|
"adb_state=${adb_state}" \
|
|
"timestamp=${ts}"
|
|
else
|
|
echo "Device: exone GmbH ${model}"
|
|
echo "Serial: ${DEVICE_SERIAL}"
|
|
echo "Android: ${android_version}"
|
|
echo "ADB State: ${adb_state}"
|
|
echo "Battery: ${battery_level}% (charging: ${battery_charging})"
|
|
echo "Storage: ${storage_used}K used / ${storage_total}K total (${storage_pct}%)"
|
|
echo "Timestamp: ${ts}"
|
|
fi
|
|
}
|
|
|
|
# ── Command: push ─────────────────────────────────────────────────────────────
|
|
cmd_push() {
|
|
local src="" dst="" show_help=false force_flag=""
|
|
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
--force) force_flag="--force" ;;
|
|
*)
|
|
if [[ -z "${src}" ]]; then src="${arg}"
|
|
elif [[ -z "${dst}" ]]; then dst="${arg}"
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${FORCE}" == "true" ]]; then force_flag="--force"; fi
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 push <local-path> <device-path> [--force]
|
|
|
|
Push a local file or directory to the device.
|
|
Use --force to overwrite existing files on the device.
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${src}" ]] || [[ -z "${dst}" ]]; then
|
|
out_error "Usage: fs5 push <src> <dst>"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
adb_push "${src}" "${dst}" "${force_flag}"
|
|
}
|
|
|
|
# ── Command: pull ─────────────────────────────────────────────────────────────
|
|
cmd_pull() {
|
|
local src="" dst="" show_help=false force_flag=""
|
|
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
--force) force_flag="--force" ;;
|
|
*)
|
|
if [[ -z "${src}" ]]; then src="${arg}"
|
|
elif [[ -z "${dst}" ]]; then dst="${arg}"
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${FORCE}" == "true" ]]; then force_flag="--force"; fi
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 pull <device-path> <local-path> [--force]
|
|
|
|
Pull a file or directory from the device to local filesystem.
|
|
Use --force to overwrite existing local files.
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${src}" ]] || [[ -z "${dst}" ]]; then
|
|
out_error "Usage: fs5 pull <src> <dst>"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
adb_pull "${src}" "${dst}" "${force_flag}"
|
|
}
|
|
|
|
# ── Command: app ──────────────────────────────────────────────────────────────
|
|
cmd_app() {
|
|
local subcmd="${1:-}"
|
|
shift || true
|
|
|
|
case "${subcmd}" in
|
|
list) cmd_app_list "$@" ;;
|
|
install) cmd_app_install "$@" ;;
|
|
uninstall) cmd_app_uninstall "$@" ;;
|
|
--help|-h)
|
|
cat <<EOF
|
|
Usage: fs5 app <subcommand> [options]
|
|
|
|
Subcommands:
|
|
list [--all] [--json] List installed apps
|
|
install <apk> Install APK
|
|
uninstall <package> --force Uninstall package
|
|
EOF
|
|
;;
|
|
*)
|
|
out_error "Unknown app subcommand: '${subcmd}'. Use: list, install, uninstall"
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
cmd_app_list() {
|
|
local show_all=false show_help=false
|
|
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--all) show_all=true ;;
|
|
--help) show_help=true ;;
|
|
--json) JSON_OUTPUT=true ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 app list [--all] [--json]"
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local pm_args="-3"
|
|
[[ "${show_all}" == "true" ]] && pm_args=""
|
|
|
|
local raw_packages
|
|
# shellcheck disable=SC2086
|
|
raw_packages=$(adb_cmd shell pm list packages ${pm_args} 2>/dev/null | tr -d '\r')
|
|
|
|
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
|
echo "${raw_packages}" | python3 -c "
|
|
import sys, json
|
|
packages = []
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if line.startswith('package:'):
|
|
packages.append({'package': line[8:]})
|
|
print(json.dumps(packages, indent=2))
|
|
"
|
|
else
|
|
echo "${raw_packages}"
|
|
fi
|
|
}
|
|
|
|
cmd_app_install() {
|
|
local apk="" show_help=false
|
|
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*) [[ -z "${apk}" ]] && apk="${arg}" ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 app install <apk-path>"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${apk}" ]]; then
|
|
out_error "Usage: fs5 app install <apk-path>"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! -f "${apk}" ]]; then
|
|
out_error "APK not found: ${apk}"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local result
|
|
result=$(adb_cmd install "${apk}" 2>&1)
|
|
if echo "${result}" | grep -q "Success"; then
|
|
adb_log_op "INSTALL" "${apk}"
|
|
out_success "Installed: ${apk}"
|
|
else
|
|
out_error "Install failed: ${result}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
cmd_app_uninstall() {
|
|
local pkg="" show_help=false force_flag=false
|
|
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
--force) force_flag=true ;;
|
|
*) [[ -z "${pkg}" ]] && pkg="${arg}" ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${FORCE}" == "true" ]]; then force_flag=true; fi
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 app uninstall <package> --force"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${pkg}" ]]; then
|
|
out_error "Usage: fs5 app uninstall <package> --force"
|
|
return 1
|
|
fi
|
|
|
|
if [[ "${force_flag}" != "true" ]]; then
|
|
out_error "Uninstall requires --force flag to prevent accidental removal"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local result
|
|
result=$(adb_cmd shell pm uninstall "${pkg}" 2>&1 | tr -d '\r')
|
|
if [[ "${result}" == "Success" ]]; then
|
|
adb_log_op "UNINSTALL" "${pkg}"
|
|
out_success "Uninstalled: ${pkg}"
|
|
else
|
|
out_error "Uninstall failed (package may not be installed): ${pkg}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ── Command: shell ────────────────────────────────────────────────────────────
|
|
cmd_shell() {
|
|
local show_help=false
|
|
local -a cmd_args=()
|
|
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*) cmd_args+=("${arg}") ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 shell [command]
|
|
|
|
Execute a shell command on the device. If no command is given,
|
|
opens an interactive shell session.
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
if [[ ${#cmd_args[@]} -eq 0 ]]; then
|
|
adb_cmd shell
|
|
else
|
|
adb_cmd shell "${cmd_args[@]}"
|
|
fi
|
|
}
|
|
|
|
# ── Command: logs ─────────────────────────────────────────────────────────────
|
|
cmd_logs() {
|
|
local lines="" filter="" output_file="" show_help=false
|
|
|
|
local i=0
|
|
local -a args=("$@")
|
|
while [[ ${i} -lt ${#args[@]} ]]; do
|
|
case "${args[${i}]}" in
|
|
--help) show_help=true ;;
|
|
--lines) i=$(( i + 1 )); lines="${args[${i}]}" ;;
|
|
--filter) i=$(( i + 1 )); filter="${args[${i}]}" ;;
|
|
--output) i=$(( i + 1 )); output_file="${args[${i}]}" ;;
|
|
esac
|
|
i=$(( i + 1 ))
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 logs [--lines N] [--filter TAG] [--output FILE]
|
|
|
|
Stream or capture Android logcat output.
|
|
|
|
Options:
|
|
--lines N Print last N lines and exit (non-streaming)
|
|
--filter TAG Filter output by log tag
|
|
--output FILE Write output to file instead of stdout
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local -a logcat_args=()
|
|
|
|
if [[ -n "${lines}" ]]; then
|
|
logcat_args+=("-d" "-t" "${lines}")
|
|
fi
|
|
|
|
if [[ -n "${filter}" ]]; then
|
|
logcat_args+=("${filter}:V" "*:S")
|
|
fi
|
|
|
|
if [[ -n "${output_file}" ]]; then
|
|
adb_cmd logcat "${logcat_args[@]+"${logcat_args[@]}"}" > "${output_file}"
|
|
out_success "Logs written to: ${output_file}"
|
|
else
|
|
adb_cmd logcat "${logcat_args[@]+"${logcat_args[@]}"}"
|
|
fi
|
|
}
|
|
|
|
# ── Command: screen ──────────────────────────────────────────────────────────
|
|
cmd_screen() {
|
|
local headless=false show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--headless) headless=true ;;
|
|
--help) show_help=true ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 screen [--headless]
|
|
|
|
Launch scrcpy to mirror and control the device screen.
|
|
--headless: run without display window (for automation/OCR only).
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
if [[ "${headless}" == "true" ]]; then
|
|
scrcpy -s "${DEVICE_SERIAL}" --no-display &
|
|
out_success "scrcpy running headless (PID $!)"
|
|
else
|
|
scrcpy -s "${DEVICE_SERIAL}"
|
|
fi
|
|
}
|
|
|
|
# ── Command: screenshot ───────────────────────────────────────────────────────
|
|
cmd_screenshot() {
|
|
local output="" show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*) [[ -z "${output}" ]] && output="${arg}" ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 screenshot [output-path.png]"
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local path
|
|
if [[ -n "${output}" ]]; then
|
|
path=$(adb_screenshot "${output}")
|
|
else
|
|
path=$(adb_screenshot)
|
|
fi
|
|
|
|
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
|
out_json "path=${path}" "timestamp=$(date -Iseconds)"
|
|
else
|
|
out_success "Screenshot saved: ${path}"
|
|
echo "${path}"
|
|
fi
|
|
}
|
|
|
|
# ── Command: tap ─────────────────────────────────────────────────────────────
|
|
cmd_tap() {
|
|
local x="" y="" show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*)
|
|
if [[ -z "${x}" ]]; then x="${arg}"
|
|
elif [[ -z "${y}" ]]; then y="${arg}"
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 tap <x> <y>"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${x}" ]] || [[ -z "${y}" ]]; then
|
|
out_error "Usage: fs5 tap <x> <y>"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
adb_tap "${x}" "${y}"
|
|
out_success "Tapped: (${x}, ${y})"
|
|
}
|
|
|
|
# ── Command: swipe ────────────────────────────────────────────────────────────
|
|
cmd_swipe() {
|
|
local x1="" y1="" x2="" y2="" duration="300" show_help=false
|
|
local -a pos=()
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*) pos+=("${arg}") ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 swipe <x1> <y1> <x2> <y2> [duration_ms]"
|
|
return 0
|
|
fi
|
|
|
|
x1="${pos[0]:-}" y1="${pos[1]:-}" x2="${pos[2]:-}" y2="${pos[3]:-}"
|
|
[[ ${#pos[@]} -ge 5 ]] && duration="${pos[4]}"
|
|
|
|
if [[ -z "${x1}" ]] || [[ -z "${y1}" ]] || [[ -z "${x2}" ]] || [[ -z "${y2}" ]]; then
|
|
out_error "Usage: fs5 swipe <x1> <y1> <x2> <y2> [duration_ms]"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
adb_swipe "${x1}" "${y1}" "${x2}" "${y2}" "${duration}"
|
|
out_success "Swiped: (${x1},${y1}) → (${x2},${y2}) in ${duration}ms"
|
|
}
|
|
|
|
# ── Command: type ─────────────────────────────────────────────────────────────
|
|
cmd_type() {
|
|
local text="" show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*) text="${text}${text:+ }${arg}" ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 type <text>"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${text}" ]]; then
|
|
out_error "Usage: fs5 type <text>"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
adb_type "${text}"
|
|
out_success "Typed: ${text}"
|
|
}
|
|
|
|
# ── Command: key ──────────────────────────────────────────────────────────────
|
|
cmd_key() {
|
|
local keycode="" show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*) [[ -z "${keycode}" ]] && keycode="${arg}" ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 key <keycode>
|
|
|
|
Send Android key event. Common keycodes:
|
|
KEYCODE_HOME KEYCODE_BACK KEYCODE_POWER
|
|
KEYCODE_ENTER KEYCODE_TAB KEYCODE_DEL
|
|
3 (HOME) 4 (BACK) 26 (POWER)
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${keycode}" ]]; then
|
|
out_error "Usage: fs5 key <keycode>"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
adb_key "${keycode}"
|
|
out_success "Key sent: ${keycode}"
|
|
}
|
|
|
|
# ── Command: launch ───────────────────────────────────────────────────────────
|
|
cmd_launch() {
|
|
local pkg="" show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*) [[ -z "${pkg}" ]] && pkg="${arg}" ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 launch <package-name>"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${pkg}" ]]; then
|
|
out_error "Usage: fs5 launch <package-name>"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
if adb_launch "${pkg}"; then
|
|
out_success "Launched: ${pkg}"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ── Command: ui ───────────────────────────────────────────────────────────────
|
|
cmd_ui() {
|
|
local subcmd="${1:-}"
|
|
shift || true
|
|
|
|
case "${subcmd}" in
|
|
dump) cmd_ui_dump "$@" ;;
|
|
elements) cmd_ui_elements "$@" ;;
|
|
find) cmd_ui_find "$@" ;;
|
|
tap) cmd_ui_tap "$@" ;;
|
|
--help|-h)
|
|
cat <<EOF
|
|
Usage: fs5 ui <subcommand> [options]
|
|
|
|
Inspect and interact with the device UI via UIAutomator.
|
|
|
|
Subcommands:
|
|
dump [path] Dump UI hierarchy XML to file
|
|
elements [--json] List all interactive UI elements
|
|
find <text> Find element by text/desc, print coordinates
|
|
tap <text> Find element by text/desc and tap it
|
|
EOF
|
|
;;
|
|
*)
|
|
out_error "Unknown ui subcommand: '${subcmd}'. Use: dump, elements, find, tap"
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
cmd_ui_dump() {
|
|
local output="" show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*) [[ -z "${output}" ]] && output="${arg}" ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 ui dump [output-path.xml]
|
|
|
|
Dump the current UI hierarchy to an XML file via UIAutomator.
|
|
If no path given, saves to /tmp/fs5-ui-<timestamp>.xml.
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local path
|
|
if [[ -n "${output}" ]]; then
|
|
path=$(adb_ui_dump "${output}")
|
|
else
|
|
path=$(adb_ui_dump)
|
|
fi
|
|
|
|
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
|
out_json "path=${path}" "timestamp=$(date -Iseconds)"
|
|
else
|
|
out_success "UI dump saved: ${path}"
|
|
echo "${path}"
|
|
fi
|
|
}
|
|
|
|
cmd_ui_elements() {
|
|
local xml_path="" show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
--json) JSON_OUTPUT=true ;;
|
|
*) [[ -z "${xml_path}" ]] && xml_path="${arg}" ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 ui elements [xml-path] [--json]
|
|
|
|
List all interactive UI elements on the current screen.
|
|
Optionally provide a previously dumped XML file.
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local json_out
|
|
if [[ -n "${xml_path}" ]]; then
|
|
json_out=$(adb_ui_elements "${xml_path}")
|
|
else
|
|
json_out=$(adb_ui_elements)
|
|
fi
|
|
|
|
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
|
echo "${json_out}"
|
|
else
|
|
# Human-readable table
|
|
echo "${json_out}" | python3 -c "
|
|
import sys, json
|
|
elements = json.load(sys.stdin)
|
|
print(f'Found {len(elements)} elements:')
|
|
for i, e in enumerate(elements):
|
|
label = e['text'] or e['content_desc'] or e['resource_id'] or e['class']
|
|
click = '✓' if e['clickable'] else ' '
|
|
cx, cy = e.get('center_x'), e.get('center_y')
|
|
coord = f'({cx},{cy})' if cx is not None else '(?)'
|
|
print(f' [{click}] {coord:>12} {label}')
|
|
"
|
|
fi
|
|
}
|
|
|
|
cmd_ui_find() {
|
|
local search_text="" xml_path="" show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*)
|
|
if [[ -z "${search_text}" ]]; then search_text="${arg}"
|
|
elif [[ -z "${xml_path}" ]]; then xml_path="${arg}"
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 ui find <text> [xml-path]
|
|
|
|
Find a UI element by text or content-desc and print its center coordinates.
|
|
Returns "x y" on success, exits 1 if not found.
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${search_text}" ]]; then
|
|
out_error "Usage: fs5 ui find <text>"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local coords
|
|
if [[ -n "${xml_path}" ]]; then
|
|
coords=$(adb_ui_find "${search_text}" "${xml_path}")
|
|
else
|
|
coords=$(adb_ui_find "${search_text}")
|
|
fi
|
|
|
|
if [[ -z "${coords}" ]]; then
|
|
out_error "Element not found: '${search_text}'"
|
|
return 1
|
|
fi
|
|
|
|
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
|
local x y
|
|
x="${coords%% *}"
|
|
y="${coords##* }"
|
|
out_json "text=${search_text}" "x=${x}" "y=${y}"
|
|
else
|
|
echo "${coords}"
|
|
fi
|
|
}
|
|
|
|
cmd_ui_tap() {
|
|
local search_text="" xml_path="" show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
*)
|
|
if [[ -z "${search_text}" ]]; then search_text="${arg}"
|
|
elif [[ -z "${xml_path}" ]]; then xml_path="${arg}"
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
cat <<EOF
|
|
Usage: fs5 ui tap <text> [xml-path]
|
|
|
|
Find a UI element by text or content-desc and tap its center.
|
|
Combines ui find + tap in one step.
|
|
EOF
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${search_text}" ]]; then
|
|
out_error "Usage: fs5 ui tap <text>"
|
|
return 1
|
|
fi
|
|
|
|
adb_check_device
|
|
|
|
local coords
|
|
if [[ -n "${xml_path}" ]]; then
|
|
coords=$(adb_ui_find "${search_text}" "${xml_path}")
|
|
else
|
|
coords=$(adb_ui_find "${search_text}")
|
|
fi
|
|
|
|
if [[ -z "${coords}" ]]; then
|
|
out_error "Element not found: '${search_text}'"
|
|
return 1
|
|
fi
|
|
|
|
local x y
|
|
x="${coords%% *}"
|
|
y="${coords##* }"
|
|
adb_tap "${x}" "${y}"
|
|
|
|
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
|
out_json "text=${search_text}" "x=${x}" "y=${y}" "action=tap"
|
|
else
|
|
out_success "Tapped '${search_text}' at (${x}, ${y})"
|
|
fi
|
|
}
|
|
|
|
# ── Command: ocr ──────────────────────────────────────────────────────────────
|
|
cmd_ocr() {
|
|
local show_help=false
|
|
for arg in "$@"; do
|
|
[[ "${arg}" == "--help" ]] && show_help=true
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 ocr"
|
|
echo "Take a screenshot and extract all visible text via OCR (tesseract)."
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
local text
|
|
text=$(adb_ocr)
|
|
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
|
python3 -c "import json,sys; print(json.dumps({'text': sys.stdin.read()}))" <<< "${text}"
|
|
else
|
|
echo "${text}"
|
|
fi
|
|
}
|
|
|
|
# ── Command: sms ──────────────────────────────────────────────────────────────
|
|
cmd_sms() {
|
|
local subcmd="${1:-}"
|
|
shift || true
|
|
|
|
case "${subcmd}" in
|
|
list) cmd_sms_list "$@" ;;
|
|
otp) cmd_sms_otp "$@" ;;
|
|
watch) cmd_sms_watch "$@" ;;
|
|
--help|-h)
|
|
cat <<EOF
|
|
Usage: fs5 sms <subcommand>
|
|
|
|
Subcommands:
|
|
list [--limit N] [--json] List recent SMS messages
|
|
otp [--wait] Extract latest OTP code from SMS
|
|
watch Watch for new SMS messages (Ctrl+C to stop)
|
|
EOF
|
|
;;
|
|
*)
|
|
out_error "Unknown sms subcommand: '${subcmd}'. Use: list, otp, watch"
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
cmd_sms_list() {
|
|
local limit=10 show_help=false
|
|
local i=0
|
|
local -a args=("$@")
|
|
while [[ ${i} -lt ${#args[@]} ]]; do
|
|
case "${args[${i}]}" in
|
|
--help) show_help=true ;;
|
|
--json) JSON_OUTPUT=true ;;
|
|
--limit) i=$(( i + 1 )); limit="${args[${i}]}" ;;
|
|
esac
|
|
i=$(( i + 1 ))
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 sms list [--limit N] [--json]"
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
adb_sms_list "${limit}"
|
|
}
|
|
|
|
cmd_sms_otp() {
|
|
local wait_secs=0 show_help=false
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--help) show_help=true ;;
|
|
--wait) wait_secs=60 ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 sms otp [--wait]"
|
|
echo "Extract latest OTP/2FA code from SMS inbox."
|
|
echo "--wait: poll up to 60 seconds for a new OTP."
|
|
return 0
|
|
fi
|
|
|
|
adb_check_device
|
|
local otp
|
|
otp=$(adb_sms_otp "${wait_secs}")
|
|
local exit_code=$?
|
|
|
|
if [[ ${exit_code} -eq 0 ]]; then
|
|
if [[ "${JSON_OUTPUT}" == "true" ]]; then
|
|
out_json "otp=${otp}" "timestamp=$(date -Iseconds)"
|
|
else
|
|
echo "${otp}"
|
|
fi
|
|
fi
|
|
return ${exit_code}
|
|
}
|
|
|
|
cmd_sms_watch() {
|
|
local show_help=false
|
|
for arg in "$@"; do
|
|
[[ "${arg}" == "--help" ]] && show_help=true
|
|
done
|
|
|
|
if [[ "${show_help}" == "true" ]]; then
|
|
echo "Usage: fs5 sms watch"
|
|
echo "Watch for new SMS/notification messages via KDE Connect. Press Ctrl+C to stop."
|
|
return 0
|
|
fi
|
|
|
|
local dev_id
|
|
dev_id=$(_kdeconnect_check) || return 1
|
|
|
|
out_success "Watching for SMS via KDE Connect notifications (Ctrl+C to stop)..."
|
|
local notif_base="/modules/kdeconnect/devices/${dev_id}/notifications"
|
|
local seen_ids=""
|
|
|
|
while true; do
|
|
local notif_ids
|
|
notif_ids=$(gdbus call --session --dest org.kde.kdeconnect \
|
|
--object-path "${notif_base}" \
|
|
--method org.kde.kdeconnect.device.notifications.activeNotifications \
|
|
2>/dev/null | grep -oE "'[^']+'" | tr -d "'" | tr ',' '\n' | tr -d ' []')
|
|
|
|
for nid in ${notif_ids}; do
|
|
# Skip already-seen notifications
|
|
if echo "${seen_ids}" | grep -qw "${nid}"; then
|
|
continue
|
|
fi
|
|
seen_ids="${seen_ids} ${nid}"
|
|
|
|
local props app title text ts
|
|
props=$(gdbus call --session --dest org.kde.kdeconnect \
|
|
--object-path "${notif_base}/${nid}" \
|
|
--method org.freedesktop.DBus.Properties.GetAll \
|
|
"org.kde.kdeconnect.device.notifications.notification" 2>/dev/null)
|
|
ts=$(date -Iseconds)
|
|
app=$(echo "${props}" | grep -oP "'appName': <'\K[^']+")
|
|
title=$(echo "${props}" | grep -oP "'title': <'\K[^']+")
|
|
text=$(echo "${props}" | grep -oP "'text': <'\K[^']+" | head -c 200)
|
|
echo "[${ts}] APP: ${app} | ${title}"
|
|
echo " ${text}"
|
|
echo ""
|
|
done
|
|
sleep 5
|
|
done
|
|
}
|
|
|
|
# ── Main dispatcher ───────────────────────────────────────────────────────────
|
|
main() {
|
|
if [[ $# -eq 0 ]]; then
|
|
usage
|
|
exit 1
|
|
fi
|
|
|
|
# Extract command (first arg)
|
|
local command="${1}"
|
|
shift
|
|
|
|
# Handle global --help before command dispatch
|
|
if [[ "${command}" == "--help" ]] || [[ "${command}" == "-h" ]]; then
|
|
usage
|
|
exit 0
|
|
fi
|
|
|
|
# Parse remaining args for global flags; remaining_args gets non-flag args
|
|
local -a remaining_args=()
|
|
parse_global_flags remaining_args "$@"
|
|
|
|
case "${command}" in
|
|
status) cmd_status "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
push) cmd_push "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
pull) cmd_pull "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
app) cmd_app "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
shell) cmd_shell "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
logs) cmd_logs "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
screen) cmd_screen "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
screenshot) cmd_screenshot "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
tap) cmd_tap "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
swipe) cmd_swipe "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
type) cmd_type "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
key) cmd_key "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
launch) cmd_launch "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
ui) cmd_ui "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
ocr) cmd_ocr "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
sms) cmd_sms "${remaining_args[@]+"${remaining_args[@]}"}" ;;
|
|
*)
|
|
out_error "Unknown command: ${command}"
|
|
usage
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|