#!/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" # ── Global flags ────────────────────────────────────────────────────────────── JSON_OUTPUT=false FORCE=false # ── Usage ───────────────────────────────────────────────────────────────────── usage() { cat < [options] Commands: 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 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 } # ── 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[@]}"}" ;; *) out_error "Unknown command: ${command}" usage exit 1 ;; esac } main "$@"