#!/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 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: 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 "Poll for new SMS messages every 5 seconds. Press Ctrl+C to stop." return 0 fi adb_check_device out_success "Watching for SMS (Ctrl+C to stop)..." local last_id="" last_id=$(adb_cmd shell content query \ --uri content://sms/inbox \ --projection "_id" \ --sort "date DESC" \ 2>/dev/null | grep "_id=" | head -1 | grep -oE '_id=[0-9]+' | cut -d= -f2 | tr -d '\r') while true; do local raw new_id raw=$(adb_cmd shell content query \ --uri content://sms/inbox \ --projection "_id,address,date,body" \ --sort "date DESC" \ 2>/dev/null | tr -d '\r' | head -10) new_id=$(echo "${raw}" | grep "_id=" | head -1 | grep -oE '_id=[0-9]+' | cut -d= -f2) if [[ -n "${new_id}" ]] && [[ "${new_id}" != "${last_id}" ]]; then local ts body address ts=$(date -Iseconds) address=$(echo "${raw}" | grep "address=" | head -1 | grep -oE 'address=[^,]+' | cut -d= -f2) body=$(echo "${raw}" | grep "body=" | head -1 | sed 's/.*body=//') echo "[${ts}] FROM: ${address}" echo " ${body}" echo "" last_id="${new_id}" fi 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[@]}"}" ;; ocr) cmd_ocr "${remaining_args[@]+"${remaining_args[@]}"}" ;; sms) cmd_sms "${remaining_args[@]+"${remaining_args[@]}"}" ;; *) out_error "Unknown command: ${command}" usage exit 1 ;; esac } main "$@"