#!/usr/bin/env bash # fs5 — Android FS5 device management CLI # Device: exone GmbH FS5 (Android 9) # Usage: ./fs5 [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 < [options] Device Management: status Show device health and info push Push file/dir to device pull Pull file/dir from device app list List installed apps app install Install APK on device app uninstall 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 Send tap at coordinates swipe [ms] Send swipe gesture type Type text into focused field key Send Android key event launch 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 Find element by text, print coordinates ui tap 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 </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 < [--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 " 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 < [--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 " 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 < [options] Subcommands: list [--all] [--json] List installed apps install Install APK uninstall --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 " return 0 fi if [[ -z "${apk}" ]]; then out_error "Usage: fs5 app install " 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 --force" return 0 fi if [[ -z "${pkg}" ]]; then out_error "Usage: fs5 app uninstall --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 < "${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 < " return 0 fi if [[ -z "${x}" ]] || [[ -z "${y}" ]]; then out_error "Usage: fs5 tap " 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 [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 [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 " return 0 fi if [[ -z "${text}" ]]; then out_error "Usage: fs5 type " 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 < 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 " 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 " return 0 fi if [[ -z "${pkg}" ]]; then out_error "Usage: fs5 launch " 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 < [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 Find element by text/desc, print coordinates tap 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 <.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 <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 < [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 " 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 < [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 " 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 < 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 "$@"