Skill: openlittlebear/12306-android-adb (version 1.0.0)

--- name: 12306-android-adb description: 12306-specific knowledge for booking train tickets. Covers app UI structure, station search behavior, booking flow, and common pitfalls. Uses the appium-android-adb bridge for all screen interaction — NEVER use raw ADB commands on 12306. --- # 12306 Train Booking 12306-specific knowledge, workflows, and pitfalls. For screen interaction, use the `appium-android-adb` skill (`bridge_daemon.py`). ## 🚨 Before Any 12306 Action Start the Appium bridge (once per session): ```bash bash ~/.openclaw/workspace/skills/appium-android-adb/start_bridge.sh ``` All screen interactions use `python3 ~/.openclaw/workspace/skills/appium-android-adb/bridge_daemon.py <command>`. ## ⚠ Never Use Raw ADB on 12306 The 12306 app uses UC WebView for the train list and booking pages. **ADB `input tap` is IGNORED.** Always use `bridge_daemon.py` from `appium-android-adb`. ## 12306 App Structure | Page | Package/Activity | How to Detect | |------|-----------------|---------------| | Home | `com.MobileTicket` / `MainActivity` | `ticket_home_btn_search` button exists | | Train list | `com.MobileTicket` / `H5Activity` | `title` contains `<>` (e.g. "上海 <> 苏州") | | Booking | same H5Activity | "预订" buttons exist | | Confirmation | same H5Activity | "提交订单" button exists | | Payment | same H5Activity | "立即支付" or "去支付" button | ### Key Element IDs (Home Page) | Element | Resource ID | |---------|------------| | Search button | `ticket_home_btn_search` | | Departure station | `home_page_train_dep1` | | Arrival station | `home_page_train_arr1` | | Date container | [REDACTED_SECRET] | ## ⚠ Station Search Behavior **Any station in a city searches ALL stations in that city.** Example: - "上海虹桥→苏州" also shows trains from 上海站, 上海南站, 上海松江 etc. - "上海→苏州北" also shows trains to 苏州站, 苏州园区, 苏州新区 etc. **Once you have ANY valid city-to-city search result, NEVER go back to change stations.** If the target train isn't immediately visible, just scroll — it's in the list. ## Booking Flow ### Step 1: Home page — ensure valid city pair + date ``` dump → check current stations and date If stations show any valid city pair (e.g. "上海虹桥→苏州" or "上海→苏州北"): → SKIP station changes. All trains from both cities will appear. If stations are completely wrong: → tap departure, pick ANY station in correct city → tap arrival, pick ANY station in correct city tap date container → pick target date tap "查询车票" ``` ### Step 2: Find and select train ``` dump → check trains[] for target train number scroll down → dump → repeat until target appears tap train by text (e.g. '{"text": "G7004"}') → train detail / booking page appears ``` ### Step 3: Book seat ``` dump → see seat options, look for "预订" buttons tap '{"text": "预订", "index": 0}' → first is usually 二等座 ``` ### Step 4: Fingerprint (manual — tell user) ``` If alert with "指纹" appears: tell user to authenticate on phone. Wait, then dump to verify. ``` ### Step 5: Confirm and submit ``` dump → verify train, passenger, seat, price If passenger not selected: tap "选择乘车人" → tap passenger name tap "提交订单" ``` ### Step 6: Payment (user handles Alipay/WeChat) ``` dump → tap "立即支付" or "去支付" → User handles Alipay/WeChat login manually ``` ## Decision Tree ``` dump the screen: ├─ package != "com.MobileTicket" → app not in foreground, tap app icon or adb shell monkey ├─ "ticket_home_btn_search" in buttons → HOME PAGE │ → ensure valid city pair (any station!) + date → tap 查询车票 ├─ title contains "<>" → TRAIN LIST │ → scroll + dump to find target train → tap it │ → NEVER go back to change stations — scroll instead ├─ "预订" in buttons → BOOKING PAGE │ → tap 预订 for seat type ├─ "提交订单" in buttons → CONFIRMATION │ → verify details → select passenger → submit ├─ "立即支付" in buttons → PAYMENT │ → tap to pay, user handles auth └─ alerts not empty → dismiss first, re-dump ``` ## Common Pitfalls - **Going back to change stations**: Don't. Any station in a city shows all trains. Just scroll. - **Train at viewport edge (h=6)**: Scroll slightly to bring it into view, then tap. - **Fingerprint dialog**: Cannot automate. Tell user to authenticate. - **Session timeout**: The bridge daemon holds a persistent session — no timeout issues. - **Wrong date on train list**: The results page has date tabs at the top. Tap the correct date there directly. ## Files - `adb_helper.py` — Low-level ADB helper (native element dump/find/tap/swipe). Use for pre-Appium checks or native-only pages. - `SKILL.md` — This file. 12306-specific knowledge. - `README.md` — Reference docs.

path: README.md
content: # 12306 Android ADB 火车票自动化

## 工作原理

12306 App 是混合架构：
- **首页**：原生 Android 控件 → ADB tap 有效
- **车次列表**：UC WebView → ADB tap 被过滤，需 uiautomator2
- **购票/支付**：WebView

### 核心发现

12306 App 使用的 UC WebView 会过滤 ADB 注入的触摸事件（`input tap`、`motionevent`、`monkey`、`sendevent` 全部失败）。只有通过 **AccessibilityService** 发起的点击（uiautomator2）才能在 WebView 上生效。

## 安装

```bash
cd ~/.openclaw/workspace/skills/12306-android-adb
pip3 install uiautomator2
python3 -m uiautomator2 init  # 手机上安装 ATX Agent (需要解锁屏幕)
```

## 使用

### 1. 查询车次
```bash
# 查询明天早上 6-10 点的车次（无需 uiautomator2 agent）
python3 book_12306.py --date 2026-05-19 --time-min 06:00 --time-max 10:00 --list-only
```

### 2. 预订车票（需要 uiautomator2）
```bash
python3 book_12306.py --train G7004 --passenger 熊天放 --date 2026-05-19
```

### 3. 试运行（在指纹验证前停止）
```bash
python3 book_12306.py --train G7004 --dry-run
```

## 流程

| 步骤 | 操作 | 方式 | 自动化 |
|------|------|------|--------|
| 1 | 打开 App 首页 | ADB native | ✅ |
| 2 | 设置日期/站点 | ADB native | ✅ |
| 3 | 查询车票 | ADB native | ✅ |
| 4 | 滚动到目标车次 | ADB swipe | ✅ |
| 5 | 点击车次 | **uiautomator2** | ✅ |
| 6 | **指纹验证** | 物理指纹 | ❌ |
| 7 | 选择座位/乘客 | uiautomator2 | ✅ |
| 8 | 提交订单 | uiautomator2 | ✅ |

## 技术调查总结

本次调查确认了 OpenClaw 失败的原因：

1. **UC WebView 拒绝 ADB 触摸事件** — 这是根本原因。所有 `input tap`、`motionevent`、`sendevent`、`monkey` 方法都尝试过，全部无法在车次列表 WebView 中触发点击。

2. **uiautomator2 使用 AccessibilityService 点击** — 这是解决方案。通过 `d(textContains="G7004").click()` 成功导航到购票页。

3. **成功订到 G7004** — 2026-05-19 08:00 上海→苏州，二等座有票，一等座余票 2 张。

4. **指纹验证无法绕过的安全边界** — 12306 要求物理指纹验证，这是自动化必须停止的点。


path: _meta.json
content: slug: 12306-android-adb
version: 1.0.0
description: Book train tickets on the 12306 Android app via USB ADB. Handles the UC WebView touch injection problem using uiautomator2.

path: adb_helper.py
content: #!/usr/bin/env python3
"""ADB UI automation helper for Android apps.

Provides element finding and interaction via uiautomator dump + XML parsing.
For WebView content that ignores ADB touches, see book_12306.py (uses uiautomator2).

Usage:
    from adb_helper import ADB

    adb = ADB()  # or ADB("device_serial")
    root = adb.parse_ui()

    # Find elements
    search_btn = adb.find_first(resource_id="ticket_home_btn_search", root=root)
    trains = adb.find(class_contains="Button", text_contains="次列车", root=root)

    # Interact (works on native elements only; WebViews need uiautomator2)
    adb.tap_el(search_btn)
    adb.swipe(540, 1600, 540, 600, 100)  # fast drag for UC WebView scrolling

Note: UC WebView (used by 12306 and many Chinese apps) filters out ADB touch
events. Native Android elements and accessibility-based clicks (uiautomator2)
are required for WebView interaction.
"""

import re
import subprocess
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class UIElement:
    text: str = ""
    content_desc: str = ""
    resource_id: str = ""
    class_name: str = ""
    bounds: str = ""
    clickable: bool = False
    enabled: bool = False
    package: str = ""
    attrib: dict = field(default_factory=dict)

    @property
    def center(self) -> tuple:
        m = re.findall(r'\d+', self.bounds)
        if len(m) == 4:
            return ((int(m[0]) + int(m[2])) // 2, (int(m[1]) + int(m[3])) // 2)
        return (0, 0)

    @property
    def height(self) -> int:
        m = re.findall(r'\d+', self.bounds)
        return int(m[3]) - int(m[1]) if len(m) == 4 else 0

    @property
    def width(self) -> int:
        m = re.findall(r'\d+', self.bounds)
        return int(m[2]) - int(m[0]) if len(m) == 4 else 0


class ADB:
    """Android Debug Bridge helper for UI automation."""

    def __init__(self, device: Optional[str] = None):
        self._prefix = ["adb"] + (["-s", device] if device else [])

    def shell(self, cmd: str, timeout: int = 30) -> str:
        r = subprocess.run(self._prefix + ["shell", cmd],
                          capture_output=True, text=True, timeout=timeout)
        return r.stdout.strip()

    def dump_ui(self) -> str:
        self.shell("uiautomator dump /sdcard/ui_dump.xml")
        subprocess.run(self._prefix + ["pull", "/sdcard/ui_dump.xml", "/tmp/adb_ui.xml"],
                      capture_output=True, text=True, timeout=15)
        with open("/tmp/adb_ui.xml") as f:
            return f.read()

    def parse_ui(self, xml_str: Optional[str] = None) -> ET.Element:
        return ET.fromstring(xml_str or self.dump_ui())

    def find(self, root=None, *, text=None, text_contains=None,
             content_desc=None, content_desc_contains=None,
             resource_id=None, class_contains=None,
             clickable=None, enabled=None) -> list[UIElement]:
        """Find UI elements matching criteria. If root is None, dumps UI first."""
        if root is None:
            root = self.parse_ui()
        results = []

        def _search(node):
            for elem in node:
                a = elem.attrib
                t = a.get("text", "")
                cd = a.get("content-desc", "")
                rid = (a.get("resource-id", "") or "").split("/")[-1]
                cls = a.get("class", "")
                clk = a.get("clickable", "false") == "true"
                ena = a.get("enabled", "false") == "true"

                tests = []
                if text is not None: tests.append(t == text)
                if text_contains is not None: tests.append(text_contains in t)
                if content_desc is not None: tests.append(cd == content_desc)
                if content_desc_contains is not None: tests.append(content_desc_contains in cd)
                if resource_id is not None: tests.append(rid == resource_id)
                if class_contains is not None: tests.append(class_contains in cls)
                if clickable is not None: tests.append(clk == clickable)
                if enabled is not None: tests.append(ena == enabled)

                if all(tests):
                    results.append(UIElement(
                        text=t, content_desc=cd, resource_id=rid,
                        class_name=cls, bounds=a.get("bounds", ""),
                        clickable=clk, enabled=ena, package=a.get("package", ""),
                        attrib=dict(a),
                    ))
                _search(elem)

        _search(root)
        return results

    def find_first(self, **kwargs) -> Optional[UIElement]:
        r = self.find(**kwargs)
        return r[0] if r else None

    def tap(self, x: int, y: int):
        self.shell(f"input tap {x} {y}")

    def tap_el(self, el: UIElement):
        self.tap(*el.center)

    def tap_motion(self, x: int, y: int, press_ms: int = 150):
        """Tap using motionevent (works on some WebViews that reject input tap)."""
        self.shell(f"input motionevent DOWN {x} {y}")
        time.sleep(press_ms / 1000.0)
        self.shell(f"input motionevent UP {x} {y}")

    def swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 500):
        self.shell(f"input swipe {x1} {y1} {x2} {y2} {ms}")

    def fast_drag(self, x1: int = 540, y1: int = 1600,
                  x2: int = 540, y2: int = 600, ms: int = 100):
        """Fast drag swipe (100ms) — works best for UC WebView scrolling."""
        self.swipe(x1, y1, x2, y2, ms)

    def press_back(self):
        self.shell("input keyevent 4")

    def launch_app(self, package: str):
        self.shell(f"monkey -p {package} -c android.intent.category.LAUNCHER 1")

    def screenshot(self, path: str = "/tmp/adb_screenshot.png"):
        self.shell("screencap -p /sdcard/adb_scr.png")
        subprocess.run(self._prefix + ["pull", "/sdcard/adb_scr.png", path],
                      capture_output=True, text=True, timeout=10)
        return path

    # ── Unicode text input ──────────────────────────────────────────

    def input_unicode(self, text: str) -> bool:
        """Input Unicode text (Chinese, etc.) using the best available method.

        Tries these strategies in order:
        1. Write to /sdcard/, use uiautomator2 ACTION_SET_TEXT (no IME needed)
        2. uiautomator2 send_keys (requires fastinput IME)
        3. Clipboard paste (may not work on all devices)
        4. ADBKeyboard broadcast (if installed)

        NOTE: For many 12306 flows, typing is NOT needed — the station picker
        and passenger selector use tap-on-suggestion. Only use this method
        when you truly need to type Chinese into a text field.
        """
        # Strategy 1: uiautomator2 ACTION_SET_TEXT on focused EditText
        if self._u2_set_text(text):
            return True

        # Strategy 2: uiautomator2 send_keys
        if self._u2_send_keys(text):
            return True

        # Strategy 3: clipboard + paste
        if self._clipboard_paste(text):
            return True

        # Strategy 4: ADBKeyboard broadcast
        if self._adbkeyboard_input(text):
            return True

        # Strategy 5: fallback — only works for ASCII
        escaped = text.replace(" ", "%s")
        try:
            result = self.shell(f"input text '{escaped}'")
            return len(result) == 0
        except Exception:
            return False

    def _u2_set_text(self, text: str) -> bool:
        """Use uiautomator2 ACTION_SET_TEXT — no IME switching needed."""
        try:
            import uiautomator2 as u2
            # Kill stale process first
            self.shell("am force-stop com.github.uiautomator 2>/dev/null")
            self.shell("am force-stop com.github.uiautomator.test 2>/dev/null")
            time.sleep(0.5)

            d = u2.connect()
            edit = d(className="android.widget.EditText", focused=True)
            if not edit.exists:
                edit = d(className="android.widget.EditText")
            if edit.exists:
                edit.set_text(text)
                return True
            return False
        except Exception:
            return False

    def _u2_send_keys(self, text: str) -> bool:
        """Use uiautomator2 send_keys (needs fastinput IME)."""
        try:
            import uiautomator2 as u2
            d = u2.connect()
            d.set_input_ime(True)
            d.send_keys(text)
            d.set_input_ime(False)
            return True
        except Exception:
            return False

    def _clipboard_paste(self, text: str) -> bool:
        """Write text to /sdcard/, attempt clipboard set + paste."""
        try:
            # Write Chinese text to file (base64 avoids encoding issues)
            import base64
            encoded = base64.b64encode(text.encode("utf-8")).decode()
            self.shell(f"echo '{encoded}' | base64 -d > /sdcard/_input.txt")
            # Some devices support cmd clipboard
            self.shell("cmd clipboard set \"$(cat /sdcard/_input.txt)\" 2>/dev/null")
            time.sleep(0.1)
            self.shell("input keyevent 279")  # KEYCODE_PASTE
            time.sleep(0.2)
            return True
        except Exception:
            return False

    def _adbkeyboard_input(self, text: str) -> bool:
        """Use ADBKeyboard IME broadcast (if installed)."""
        try:
            self.shell(f"am broadcast -a ADB_INPUT_TEXT --es msg '{text}' 2>/dev/null")
            return True
        except Exception:
            return False

    def current_package(self) -> str:
        out = self.shell("dumpsys window | grep mCurrentFocus")
        parts = out.strip().split()
        for p in parts:
            if "/" in p:
                return p.split("/")[0]
        return ""


# ─── Convenience ───────────────────────────────────────────────────────

def dump_tree(root: ET.Element, max_depth: int = 4) -> str:
    """Pretty-print accessibility tree for debugging."""
    lines = []

    def _dump(node, depth=0):
        if depth > max_depth:
            return
        for elem in node:
            a = elem.attrib
            cls = a.get('class', '').split('.')[-1]
            t = a.get('text', '')
            cd = a.get('content-desc', '')
            bounds = a.get('bounds', '')
            clk = a.get('clickable', '')
            marker = '✓' if clk == 'true' else ' '
            desc = t or cd or ''
            lines.append(f"{'  ' * depth}[{marker}] {cls} {bounds} {desc[:80]}")
            _dump(elem, depth + 1)

    _dump(root)
    return '\n'.join(lines)


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