Skill: openlittlebear/appium-android-adb (version 1.0.1)

--- name: appium-android-adb description: Read and control any Android app via Appium. Provides a persistent bridge daemon (bridge_daemon.py) with dump/tap/scroll/type/wait commands. Use when raw ADB taps don't work (WebView apps, UC WebView) or when you need structured screen reading. Start with start_bridge.sh once, then use bridge_daemon.py for all interactions at ~1-2s per call. --- # Appium Android Bridge Generic Appium bridge for any Android app. Reads screens as structured JSON, executes taps/scrolling/typing via accessibility service clicks (bypasses WebView touch filtering). ## Quick Start ```bash # Once per session (28s, starts Appium + daemon): bash ~/.openclaw/workspace/skills/appium-android-adb/start_bridge.sh # Then every action (~1-2s each): python3 ~/.openclaw/workspace/skills/appium-android-adb/bridge_daemon.py dump python3 ~/.openclaw/workspace/skills/appium-android-adb/bridge_daemon.py tap '{"text": "查询"}' python3 ~/.openclaw/workspace/skills/appium-android-adb/bridge_daemon.py scroll '{"direction": "down"}' ``` ## Commands All from `~/.openclaw/workspace/skills/appium-android-adb/`. All return JSON. ### dump — read the screen ```bash python3 bridge_daemon.py dump ``` Returns: ```json { "ok": true, "package": "com.example.app", "activity": "...", "title": "...", "alerts": [{"text": "温馨提示"}], "buttons": [{"text": "查询", "id": "btn_search", "bounds": "[99,750][981,882]"}], "trains": [{"text": "...", "bounds": "...", "clickable": true}], "webview_contexts": ["NATIVE_APP"] } ``` - `buttons[]` — all clickable elements with text, id, bounds - `alerts[]` — dialogs/popups needing dismissal - `trains[]` — elements containing "次列车" (app-agnostic; rename for other apps) - `title` — page title if present ### tap — click an element ```bash python3 bridge_daemon.py tap '{"text": "查询车票"}' # by text (substring match) python3 bridge_daemon.py tap '{"text": "预订", "index": 0}' # first match python3 bridge_daemon.py tap '{"id": "btn_submit"}' # by resource-id ``` ### scroll — swipe the screen ```bash python3 bridge_daemon.py scroll '{"direction": "down"}' # normal python3 bridge_daemon.py scroll '{"direction": "down", "distance": "short"}' # fine python3 bridge_daemon.py scroll '{"direction": "up"}' # go back ``` Uses fast drag (100ms) — works best for WebView content. ### type — input text ```bash python3 bridge_daemon.py type '{"text": "上海"}' ``` ### wait — poll until element appears ```bash python3 bridge_daemon.py wait '{"text": "提交订单", "timeout": 30}' ``` ## When to Use This Use `appium-android-adb` when: - The app uses WebView (ADB taps are ignored) - You need structured screen reading (not screenshot-based) - You need reliable element clicking (accessibility service, not raw touch) - You're automating a hybrid app (native + WebView) Do NOT use for: - Simple native-only apps where `adb shell input tap` works fine - One-off screenshots (use `adb shell screencap`) ## Troubleshooting **Daemon died**: Run `start_bridge.sh` again to restart. **Device not found**: Check USB connection: `adb devices` **App not launching**: The daemon attaches to the running app. Cold-start the app manually if needed: `adb shell monkey -p <package> 1`

path: _meta.json
content: ownerId: kn7dtmyd3zhcg0k08a9fe0gdbn87e4cp
slug: appium-android-adb
version: 1.0.0
publishedAt: 1779807445375

path: bridge_daemon.py
content: #!/usr/bin/env python3
"""
Persistent Appium session daemon for 12306 bridge.

Runs as a background process holding one Appium session.
Accepts JSON commands on stdin, returns JSON responses on stdout.
Keeps the session alive between commands — no per-call session overhead.

Start:   python3 bridge_daemon.py &
Stop:    echo '{"action":"quit"}' > /tmp/bridge_cmd

Or use the bridge_daemon.sh wrapper to send commands.
"""

import json
import os
import sys
import time
import warnings
import xml.etree.ElementTree as ET
from pathlib import Path

warnings.filterwarnings("ignore", message=".*urllib3 v2.*OpenSSL.*")
os.environ["PYTHONWARNINGS"] = "ignore"

APPIUM_URL = "http://127.0.0.1:4723"
PACKAGE = "com.MobileTicket"
CMD_FILE = Path("/tmp/bridge_cmd")
RESP_FILE = Path("/tmp/bridge_resp")
LOCK_FILE = Path("/tmp/bridge.lock")

_driver = None


def _ensure_appium():
    import subprocess, urllib.request
    try:
        r = urllib.request.urlopen(f"{APPIUM_URL}/status", timeout=2)
        if r.status == 200:
            return
    except Exception:
        pass
    # Auto-detect Android SDK path (Linux vs macOS)
    android_home = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
    if not android_home:
        for candidate in [
            os.path.expanduser("~/android-sdk"),
            os.path.expanduser("~/Library/Android/sdk"),
            "/usr/lib/android-sdk",
        ]:
            if os.path.isdir(candidate):
                android_home = candidate
                break
    if not android_home:
        android_home = os.path.expanduser("~/android-sdk")  # Linux default

    subprocess.Popen(
        ["appium", "--allow-insecure", "all", "--relaxed-security",
         "--log", "/tmp/appium.log"],
        env={**os.environ, "ANDROID_HOME": android_home,
             "ANDROID_SDK_ROOT": android_home},
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
    )
    time.sleep(4)


def _get_driver():
    global _driver
    from appium import webdriver
    from appium.options.android import UiAutomator2Options
    from selenium.common.exceptions import WebDriverException

    if _driver is not None:
        try:
            _driver.get_window_size()
            return _driver
        except WebDriverException:
            _driver = None
        except Exception:
            _driver = None

    # Cold start 12306 if needed
    import subprocess
    r = subprocess.run(["adb", "shell", "pidof", PACKAGE], capture_output=True, text=True, timeout=5)
    if not r.stdout.strip():
        subprocess.run(
            ["adb", "shell", "monkey", "-p", PACKAGE, "-c", "android.intent.category.LAUNCHER", "1"],
            capture_output=True, timeout=10)
        time.sleep(2)

    options = UiAutomator2Options()
    options.platform_name = "Android"
    options.app_package = PACKAGE
    options.no_reset = True
    options.auto_launch = False
    options.automation_name = "UiAutomator2"
    options.new_command_timeout = 600
    options.ignore_hidden_api_policy_error = True
    options.skip_server_installation = True
    options.skip_device_initialization = True

    _driver = webdriver.Remote(APPIUM_URL, options=options)
    time.sleep(2)

    if PACKAGE not in (_driver.current_package or ""):
        _driver.activate_app(PACKAGE)
        time.sleep(1)

    return _driver


def cmd_dump(_args):
    driver = _get_driver()
    result = {"package": driver.current_package, "activity": driver.current_activity,
              "alerts": [], "buttons": [], "trains": [], "webview_contexts": []}

    try:
        source = driver.page_source
        root = ET.fromstring(source)
    except Exception as e:
        return {"ok": False, "error": str(e)[:200]}

    btn_count = 0
    train_count = 0
    for elem in root.iter():
        a = elem.attrib
        text = (a.get("text", "") or "").strip()
        cd = (a.get("content-desc", "") or "").strip()
        rid = (a.get("resource-id", "") or "").split("/")[-1]
        cls = a.get("class", "") or ""
        bounds = a.get("bounds", "") or ""
        clickable = a.get("clickable", "") == "true"

        if any(kw in text for kw in ["提示", "温馨提示", "确定要", "放弃支付"]):
            if clickable:
                result["alerts"].append({"text": text[:200]})

        if ("title" in rid.lower() or "h5_title" in rid) and text:
            result["title"] = text

        if "次列车" in text and "经停" not in text and len(text) > 10:
            if train_count < 40:
                result["trains"].append({"text": text[:300], "bounds": bounds, "clickable": clickable})
            train_count += 1

        if clickable and (text or cd or rid):
            if btn_count < 100:
                result["buttons"].append({"text": (text or cd or rid)[:200], "id": rid, "bounds": bounds})
            btn_count += 1

    return {"ok": True, **result}


def cmd_tap(args):
    driver = _get_driver()
    text = args.get("text", "")
    eid = args.get("id", args.get("element_id", ""))
    idx = args.get("index", 0)
    el = None

    if eid:
        try:
            el = driver.find_element("id", eid)
        except Exception:
            pass
    if not el and text:
        escaped = text.replace('"', '\\"')
        try:
            matches = driver.find_elements("xpath", f'//*[contains(@text, "{escaped}")]')
            if matches:
                el = matches[min(idx, len(matches) - 1)]
        except Exception:
            pass

    if not el:
        return {"ok": False, "error": f"not found: '{text}'"}

    bounds = ""
    try:
        bounds = el.get_attribute("bounds") or ""
    except Exception:
        pass
    el.click()
    time.sleep(1)
    return {"ok": True, "tapped": text, "bounds": bounds, "activity": driver.current_activity}


def cmd_scroll(args):
    driver = _get_driver()
    direction = args.get("direction", "down")
    dist = args.get("distance", "normal")
    if dist == "short":
        y1, y2 = (1300, 900) if direction == "down" else (900, 1300)
    elif dist == "long":
        y1, y2 = (1800, 400) if direction == "down" else (400, 1800)
    else:
        y1, y2 = (1600, 600) if direction == "down" else (600, 1600)
    dur = args.get("duration", 100)
    driver.swipe(540, y1, 540, y2, dur)
    time.sleep(0.4)
    return {"ok": True, "direction": direction}


def cmd_type(args):
    driver = _get_driver()
    text = args.get("text", "")
    if not text:
        return {"ok": False, "error": "no text"}
    try:
        el = driver.find_element("xpath", '//android.widget.EditText')
        el.click()
        time.sleep(0.2)
        el.send_keys(text)
        return {"ok": True, "typed": text}
    except Exception as e:
        return {"ok": False, "error": str(e)[:200]}


def cmd_wait(args):
    """Wait for a specific text/element to appear on screen (polling)."""
    timeout = args.get("timeout", 30)
    text = args.get("text", "")
    driver = _get_driver()
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            if text:
                matches = driver.find_elements("xpath", f'//*[contains(@text, "{text}")]')
                if matches:
                    return {"ok": True, "found": text, "after": f"{timeout - (deadline - time.time()):.1f}s"}
            time.sleep(1)
        except Exception:
            time.sleep(1)
    return {"ok": False, "error": f"timeout waiting for '{text}'"}


ACTIONS = {
    "dump": cmd_dump, "tap": cmd_tap, "scroll": cmd_scroll,
    "type": cmd_type, "wait": cmd_wait,
}


def run_daemon():
    """Main loop: watch CMD_FILE for commands, write responses to RESP_FILE."""
    global _driver
    LOCK_FILE.write_text(str(os.getpid()))

    # Clean up on exit
    import atexit, signal
    def cleanup():
        try:
            _driver.quit()
        except Exception:
            pass
        LOCK_FILE.unlink(missing_ok=True)
        CMD_FILE.unlink(missing_ok=True)
        RESP_FILE.unlink(missing_ok=True)
    atexit.register(cleanup)
    for sig in (signal.SIGTERM, signal.SIGINT):
        try:
            signal.signal(sig, lambda s, f: sys.exit(0))
        except Exception:
            pass

    _ensure_appium()
    _get_driver()  # Pre-warm

    while True:
        if CMD_FILE.exists():
            try:
                raw = CMD_FILE.read_text().strip()
                CMD_FILE.unlink()
                if not raw:
                    continue
                cmd = json.loads(raw)
                action = cmd.get("action", "dump")
                args = cmd.get("args", {})
            except (json.JSONDecodeError, ValueError):
                RESP_FILE.write_text(json.dumps({"ok": False, "error": "invalid json"}))
                continue

            if action == "quit":
                try:
                    _driver.quit()
                except Exception:
                    pass
                RESP_FILE.write_text(json.dumps({"ok": True, "message": "daemon stopped"}))
                break

            handler = ACTIONS.get(action, lambda a: {"ok": False, "error": f"unknown: {action}"})
            try:
                result = handler(args)
            except Exception as e:
                # Session maybe died — recreate and retry once
                try:
                    _driver = None
                    result = handler(args)
                except Exception as e2:
                    result = {"ok": False, "error": str(e2)[:300]}

            RESP_FILE.write_text(json.dumps(result, ensure_ascii=False, default=str))

        time.sleep(0.1)


# ─── CLI wrapper (for backwards compatibility) ──────────────────────────

def run_one_shot():
    """Single command mode — send to daemon, wait for response."""
    if len(sys.argv) < 2:
        print(json.dumps({"ok": False, "error": "usage: bridge_daemon.py <action> [json_args]"}))
        sys.exit(1)

    action = sys.argv[1]
    args = {}
    if len(sys.argv) > 2:
        try:
            args = json.loads(sys.argv[2])
        except json.JSONDecodeError:
            print(json.dumps({"ok": False, "error": "invalid json args"}))
            sys.exit(1)

    # Send command to daemon
    cmd = json.dumps({"action": action, "args": args})
    CMD_FILE.write_text(cmd)

    # Wait for response
    deadline = time.time() + 60
    while time.time() < deadline:
        if RESP_FILE.exists():
            resp = RESP_FILE.read_text().strip()
            RESP_FILE.unlink()
            print(resp)
            return
        time.sleep(0.1)

    print(json.dumps({"ok": False, "error": "daemon timeout"}))


def _daemon_alive():
    """Check if the daemon process from LOCK_FILE is actually running."""
    if not LOCK_FILE.exists():
        return False
    try:
        pid = int(LOCK_FILE.read_text().strip())
        os.kill(pid, 0)  # Signal 0 = just check if process exists
        return True
    except (ValueError, OSError, ProcessLookupError):
        return False


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "--daemon":
        run_daemon()
    else:
        # Auto-start daemon if not running, then send command
        if not _daemon_alive():
            import subprocess
            LOCK_FILE.unlink(missing_ok=True)
            proc = subprocess.Popen(
                [sys.executable, __file__, "--daemon"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            )
            # Daemon writes its own PID to LOCK_FILE on startup
            time.sleep(4)  # Wait for daemon to start + pre-warm session

        run_one_shot()


path: start_bridge.sh
content: #!/bin/bash
# Start the Appium bridge daemon (one-time setup)
# Usage: bash start_bridge.sh

set -e
echo "[*] Starting Appium bridge..."

# 1. Clean up all stale state
pkill -f bridge_daemon 2>/dev/null || true
rm -f /tmp/bridge_cmd /tmp/bridge_resp /tmp/bridge.lock
sleep 1

# 2. Verify ADB connection
if ! adb devices | grep -q "device$"; then
    echo "[!] No ADB device connected. Check USB cable."
    exit 1
fi
echo "  [✓] ADB device found"

# 3. Start Appium if not running
if ! curl -s http://127.0.0.1:4723/status 2>/dev/null | grep -q '"ready":true'; then
    echo "[*] Starting Appium..."
    if [ -z "$ANDROID_HOME" ]; then
        for d in "$HOME/android-sdk" "$HOME/Library/Android/sdk" "/usr/lib/android-sdk"; do
            if [ -d "$d" ]; then export ANDROID_HOME="$d"; break; fi
        done
    fi
    export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
    nohup appium --allow-insecure all --relaxed-security --log /tmp/appium.log > /dev/null 2>&1 &
    sleep 4
fi

# 4. Verify Appium
if curl -s http://127.0.0.1:4723/status 2>/dev/null | grep -q '"ready":true'; then
    echo "  [✓] Appium running"
else
    echo "  [!] Appium failed to start"
    exit 1
fi

# 5. Start daemon (pre-warms session)
rm -f /tmp/bridge_cmd /tmp/bridge_resp
python3 ~/.openclaw/workspace/skills/12306-android-adb/bridge_daemon.py --daemon &
sleep 6  # Wait for daemon to create session

# 6. Test daemon with a quick dump
RESP=$(python3 ~/.openclaw/workspace/skills/12306-android-adb/bridge_daemon.py dump 2>/dev/null)
if echo "$RESP" | grep -q '"ok":true'; then
    echo "  [✓] Bridge daemon ready"
    echo "$RESP" | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'     Package: {d[\"package\"]}')" 2>/dev/null
else
    echo "  [!] Daemon test failed: $RESP"
    exit 1
fi

echo "[✓] Bridge is ready. Use bridge_daemon.py for all 12306 interactions."


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)