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
330 lines
10 KiB
Bash
330 lines
10 KiB
Bash
#!/usr/bin/env bash
|
|
# lib/input.sh — ADB input automation and screenshot functions for android-fs5
|
|
# shellcheck shell=bash
|
|
|
|
# adb_screenshot [OUTPUT_PATH] — capture device screen to PNG
|
|
# Returns path of saved file
|
|
adb_screenshot() {
|
|
local output="${1:-/tmp/fs5-screenshot-$(date +%s).png}"
|
|
adb_cmd shell screencap -p > "${output}"
|
|
echo "${output}"
|
|
}
|
|
|
|
# adb_tap X Y — send tap event at coordinates
|
|
adb_tap() {
|
|
local x="${1}" y="${2}"
|
|
adb_cmd shell input tap "${x}" "${y}"
|
|
}
|
|
|
|
# adb_swipe X1 Y1 X2 Y2 [DURATION_MS] — send swipe gesture
|
|
adb_swipe() {
|
|
local x1="${1}" y1="${2}" x2="${3}" y2="${4}"
|
|
local duration="${5:-300}"
|
|
adb_cmd shell input swipe "${x1}" "${y1}" "${x2}" "${y2}" "${duration}"
|
|
}
|
|
|
|
# adb_type TEXT — type text into focused input field
|
|
adb_type() {
|
|
local text="${1}"
|
|
# Escape spaces for adb input text
|
|
local escaped="${text// /%s}"
|
|
adb_cmd shell input text "${escaped}"
|
|
}
|
|
|
|
# adb_key KEYCODE — send Android key event
|
|
# Common: KEYCODE_HOME, KEYCODE_BACK, KEYCODE_POWER, KEYCODE_ENTER
|
|
adb_key() {
|
|
local keycode="${1}"
|
|
adb_cmd shell input keyevent "${keycode}"
|
|
}
|
|
|
|
# adb_launch PACKAGE — launch app by package name
|
|
adb_launch() {
|
|
local pkg="${1}"
|
|
local result exit_code
|
|
result=$(adb_cmd shell monkey -p "${pkg}" -c android.intent.category.LAUNCHER 1 2>&1) || exit_code=$?
|
|
exit_code="${exit_code:-0}"
|
|
if echo "${result}" | grep -q "Events injected: 1"; then
|
|
return 0
|
|
elif [[ "${exit_code}" -ne 0 ]] || echo "${result}" | grep -q "No activities found\|monkey aborted"; then
|
|
out_error "Package not found or has no launcher activity: ${pkg}"
|
|
return 1
|
|
else
|
|
out_error "Failed to launch: ${pkg}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# adb_ui_dump [OUTPUT_PATH] — dump UI hierarchy via UIAutomator
|
|
# Returns local path to the XML file
|
|
adb_ui_dump() {
|
|
local output="${1:-/tmp/fs5-ui-$(date +%s).xml}"
|
|
local device_path="/sdcard/fs5_ui_dump.xml"
|
|
# UIAutomator prints permission warnings to stderr but still works
|
|
adb_cmd shell uiautomator dump "${device_path}" >/dev/null 2>&1
|
|
adb_cmd pull "${device_path}" "${output}" >/dev/null 2>&1
|
|
adb_cmd shell rm -f "${device_path}" >/dev/null 2>&1
|
|
echo "${output}"
|
|
}
|
|
|
|
# adb_ui_find TEXT [XML_PATH] — find UI element by text/content-desc, return bounds center
|
|
# Returns "x y" coordinates of element center, or empty if not found
|
|
adb_ui_find() {
|
|
local search_text="${1}"
|
|
local xml_path="${2:-}"
|
|
|
|
if [[ -z "${xml_path}" ]]; then
|
|
xml_path=$(adb_ui_dump)
|
|
fi
|
|
|
|
python3 - "${xml_path}" "${search_text}" <<'PYEOF'
|
|
import sys, xml.etree.ElementTree as ET, re
|
|
|
|
xml_path = sys.argv[1]
|
|
search = sys.argv[2].lower()
|
|
|
|
try:
|
|
tree = ET.parse(xml_path)
|
|
except Exception as e:
|
|
sys.exit(1)
|
|
|
|
root = tree.getroot()
|
|
for node in root.iter('node'):
|
|
text = node.get('text', '').lower()
|
|
desc = node.get('content-desc', '').lower()
|
|
rid = node.get('resource-id', '').lower()
|
|
if search in text or search in desc or search in rid:
|
|
bounds = node.get('bounds', '')
|
|
# Parse [x1,y1][x2,y2]
|
|
m = re.findall(r'\d+', bounds)
|
|
if len(m) == 4:
|
|
cx = (int(m[0]) + int(m[2])) // 2
|
|
cy = (int(m[1]) + int(m[3])) // 2
|
|
print(f"{cx} {cy}")
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
PYEOF
|
|
}
|
|
|
|
# adb_ui_elements [XML_PATH] — list all interactive UI elements with bounds
|
|
# Returns JSON array of {text, resource_id, class, bounds, clickable, center_x, center_y}
|
|
adb_ui_elements() {
|
|
local xml_path="${1:-}"
|
|
|
|
if [[ -z "${xml_path}" ]]; then
|
|
xml_path=$(adb_ui_dump)
|
|
fi
|
|
|
|
python3 - "${xml_path}" <<'PYEOF'
|
|
import sys, xml.etree.ElementTree as ET, re, json
|
|
|
|
xml_path = sys.argv[1]
|
|
try:
|
|
tree = ET.parse(xml_path)
|
|
except Exception as e:
|
|
print(json.dumps([]))
|
|
sys.exit(0)
|
|
|
|
root = tree.getroot()
|
|
elements = []
|
|
for node in root.iter('node'):
|
|
text = node.get('text', '')
|
|
desc = node.get('content-desc', '')
|
|
rid = node.get('resource-id', '')
|
|
cls = node.get('class', '')
|
|
bounds = node.get('bounds', '')
|
|
clickable = node.get('clickable', 'false') == 'true'
|
|
enabled = node.get('enabled', 'false') == 'true'
|
|
|
|
# Include clickable elements or elements with text/desc
|
|
if not (clickable or text or desc):
|
|
continue
|
|
|
|
cx, cy = None, None
|
|
m = re.findall(r'\d+', bounds)
|
|
if len(m) == 4:
|
|
cx = (int(m[0]) + int(m[2])) // 2
|
|
cy = (int(m[1]) + int(m[3])) // 2
|
|
|
|
elements.append({
|
|
'text': text,
|
|
'content_desc': desc,
|
|
'resource_id': rid,
|
|
'class': cls.split('.')[-1], # short class name
|
|
'bounds': bounds,
|
|
'clickable': clickable,
|
|
'enabled': enabled,
|
|
'center_x': cx,
|
|
'center_y': cy,
|
|
})
|
|
|
|
print(json.dumps(elements, indent=2))
|
|
PYEOF
|
|
}
|
|
|
|
# adb_ocr [SCREENSHOT_PATH] — screenshot + OCR, return extracted text
|
|
adb_ocr() {
|
|
local screenshot
|
|
screenshot=$(adb_screenshot)
|
|
if ! command -v tesseract &>/dev/null; then
|
|
out_error "tesseract not found. Install: sudo pacman -S tesseract tesseract-data-eng"
|
|
return 1
|
|
fi
|
|
# Locate tessdata — try common paths
|
|
local tessdata=""
|
|
for d in /usr/share/tessdata /usr/share/tesseract-ocr/4.00/tessdata \
|
|
/usr/share/tesseract-ocr/tessdata /usr/local/share/tessdata; do
|
|
if [[ -f "${d}/eng.traineddata" ]]; then
|
|
tessdata="${d}"
|
|
break
|
|
fi
|
|
done
|
|
if [[ -z "${tessdata}" ]]; then
|
|
out_error "tesseract eng language data not found. Install: sudo pacman -S tesseract-data-eng"
|
|
return 1
|
|
fi
|
|
local txt_base="${screenshot%.png}"
|
|
TESSDATA_PREFIX="${tessdata}" tesseract "${screenshot}" "${txt_base}" -l eng --psm 6 quiet 2>/dev/null
|
|
if [[ -f "${txt_base}.txt" ]]; then
|
|
cat "${txt_base}.txt"
|
|
rm -f "${txt_base}.txt"
|
|
fi
|
|
rm -f "${screenshot}"
|
|
}
|
|
|
|
# _kdeconnect_device_id — find the paired+reachable FS5 device ID via kdeconnect-cli
|
|
# Returns device ID or empty string if not paired/reachable
|
|
_kdeconnect_device_id() {
|
|
# --list-available returns only paired AND reachable devices
|
|
kdeconnect-cli --list-available --id-only 2>/dev/null | head -1 | tr -d '\r\n'
|
|
}
|
|
|
|
# _kdeconnect_check — verify KDE Connect is paired; print setup instructions if not
|
|
_kdeconnect_check() {
|
|
local dev_id
|
|
dev_id=$(_kdeconnect_device_id)
|
|
if [[ -z "${dev_id}" ]]; then
|
|
out_error "KDE Connect: no paired device found."
|
|
echo "Setup steps:"
|
|
echo " 1. Install KDE Connect on Linux: sudo pacman -S kdeconnect"
|
|
echo " 2. Open KDE Connect app on the FS5 phone"
|
|
echo " 3. Accept the pairing request on both devices"
|
|
echo " 4. Grant SMS permission in KDE Connect app on phone"
|
|
echo " 5. Re-run this command"
|
|
return 1
|
|
fi
|
|
echo "${dev_id}"
|
|
}
|
|
|
|
# adb_sms_list [LIMIT] — list SMS conversations via KDE Connect DBus
|
|
# Returns JSON array of {id, message_count} (KDE Connect 21+ only provides metadata via DBus)
|
|
# Full message content requires KDE Connect app on phone; OTPs use notifications plugin
|
|
# Requires: KDE Connect paired + SMS plugin + SMS permission granted on phone
|
|
adb_sms_list() {
|
|
local limit="${1:-10}"
|
|
local dev_id
|
|
dev_id=$(_kdeconnect_check) || return 1
|
|
|
|
local dev_path="/modules/kdeconnect/devices/${dev_id}"
|
|
local sms_path="${dev_path}/sms"
|
|
|
|
# KDE Connect 21+ DBus SMS API:
|
|
# requestAllConversations → fires conversationLoaded(int64 id, uint64 count) signals
|
|
# Message body content is NOT exposed via DBus in KDE Connect 25.x
|
|
# Use select() for non-blocking reads to avoid hanging
|
|
python3 /dev/stdin "${dev_id}" "${limit}" <<'PYEOF'
|
|
import sys, json, subprocess, time, re, select
|
|
|
|
dev_id = sys.argv[1]
|
|
limit = int(sys.argv[2])
|
|
sms_path = f"/modules/kdeconnect/devices/{dev_id}/sms"
|
|
|
|
# Start monitoring
|
|
proc = subprocess.Popen(
|
|
["dbus-monitor", "--session", "sender='org.kde.kdeconnect'"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True
|
|
)
|
|
time.sleep(0.3)
|
|
|
|
# Request all conversations
|
|
subprocess.run(
|
|
["gdbus", "call", "--session", "--dest", "org.kde.kdeconnect",
|
|
"--object-path", sms_path,
|
|
"--method", "org.kde.kdeconnect.device.sms.requestAllConversations"],
|
|
capture_output=True, text=True)
|
|
|
|
conv_list = []
|
|
deadline = time.time() + 6
|
|
last_signal = time.time()
|
|
|
|
while time.time() < deadline:
|
|
ready = select.select([proc.stdout], [], [], 0.5)
|
|
if not ready[0]:
|
|
if conv_list and (time.time() - last_signal) > 1.5:
|
|
break
|
|
continue
|
|
line = proc.stdout.readline()
|
|
if "member=conversationLoaded" in line:
|
|
last_signal = time.time()
|
|
id_line = proc.stdout.readline()
|
|
count_line = proc.stdout.readline()
|
|
m_id = re.search(r'int64\s+(\d+)', id_line)
|
|
m_cnt = re.search(r'uint64\s+(\d+)', count_line)
|
|
if m_id:
|
|
conv_list.append({
|
|
"id": int(m_id.group(1)),
|
|
"message_count": int(m_cnt.group(1)) if m_cnt else 0
|
|
})
|
|
if len(conv_list) >= limit:
|
|
break
|
|
|
|
proc.terminate()
|
|
print(json.dumps(conv_list[:limit], indent=2))
|
|
PYEOF
|
|
}
|
|
|
|
# adb_sms_otp [WAIT_SECS] — extract OTP code from KDE Connect notifications
|
|
# Watches for SMS/messaging app notifications containing 4-8 digit codes
|
|
# If WAIT_SECS > 0, polls until new OTP arrives or timeout
|
|
adb_sms_otp() {
|
|
local wait_secs="${1:-0}"
|
|
local dev_id
|
|
dev_id=$(_kdeconnect_check) || return 1
|
|
|
|
local deadline=$(( $(date +%s) + wait_secs ))
|
|
local notif_base="/modules/kdeconnect/devices/${dev_id}/notifications"
|
|
|
|
# Poll KDE Connect notifications DBus for OTP patterns
|
|
while true; do
|
|
local otp
|
|
# Get active notification IDs
|
|
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
|
|
local props
|
|
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)
|
|
# Extract OTP from text/ticker fields
|
|
otp=$(echo "${props}" | grep -oE '\b[0-9]{4,8}\b' | head -1)
|
|
if [[ -n "${otp}" ]]; then
|
|
echo "${otp}"
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
if [[ "${wait_secs}" -le 0 ]] || [[ $(date +%s) -ge ${deadline} ]]; then
|
|
break
|
|
fi
|
|
sleep 3
|
|
done
|
|
|
|
out_error "No OTP found in KDE Connect notifications"
|
|
return 1
|
|
}
|