purewhiter--mobilegym
861 行
32 KiB
Python
861 行
32 KiB
Python
"""
|
|
RealDeviceEnv - ADB-based real device environment.
|
|
|
|
This module provides a lightweight ADB interface for controlling real Android devices
|
|
or standard emulators. It supports visual-based agents (VLM) through screenshots and
|
|
basic touch interactions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import shlex
|
|
import subprocess
|
|
from typing import Any, Optional, Tuple, TYPE_CHECKING
|
|
|
|
from bench_env.logger import get_logger
|
|
from bench_env.env.base import Action, ActionType, BaseMobileEnv, Observation, StepResult
|
|
|
|
if TYPE_CHECKING:
|
|
from bench_env.task.base import BaseTask
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
# App name -> package name mapping (from Open-AutoGLM)
|
|
# Supports both Chinese names and English aliases
|
|
APP_PACKAGES: dict[str, str] = {
|
|
# ==================== Mobile-Gym Simulator Apps ====================
|
|
# WeChat
|
|
"微信": "com.tencent.mm",
|
|
"WeChat": "com.tencent.mm",
|
|
"wechat": "com.tencent.mm",
|
|
# RedBook (小红书)
|
|
"小红书": "com.xingin.xhs",
|
|
"RedBook": "com.xingin.xhs",
|
|
"redbook": "com.xingin.xhs",
|
|
"xiaohongshu": "com.xingin.xhs",
|
|
# Alipay (支付宝)
|
|
"支付宝": "com.eg.android.AlipayGphone",
|
|
"Alipay": "com.eg.android.AlipayGphone",
|
|
"alipay": "com.eg.android.AlipayGphone",
|
|
# Bilibili (B站)
|
|
"哔哩哔哩": "tv.danmaku.bili",
|
|
"bilibili": "tv.danmaku.bili",
|
|
"Bilibili": "tv.danmaku.bili",
|
|
"B站": "tv.danmaku.bili",
|
|
# Map (地图) - Google Maps
|
|
"Map": "com.google.android.apps.maps",
|
|
"map": "com.google.android.apps.maps",
|
|
"Google Maps": "com.google.android.apps.maps",
|
|
"googlemaps": "com.google.android.apps.maps",
|
|
# 高德地图
|
|
"高德地图": "com.autonavi.minimap",
|
|
"amap": "com.autonavi.minimap",
|
|
"gaode": "com.autonavi.minimap",
|
|
# QQ Music (QQ音乐)
|
|
"QQ音乐": "com.tencent.qqmusic",
|
|
"QQMusic": "com.tencent.qqmusic",
|
|
"qqmusic": "com.tencent.qqmusic",
|
|
# Spotify
|
|
"Spotify": "com.spotify.music",
|
|
"spotify": "com.spotify.music",
|
|
# Tencent Meeting (腾讯会议)
|
|
"腾讯会议": "com.tencent.wemeet.app",
|
|
"TencentMeeting": "com.tencent.wemeet.app",
|
|
"tencent_meeting": "com.tencent.wemeet.app",
|
|
"tencentmeeting": "com.tencent.wemeet.app",
|
|
"wemeet": "com.tencent.wemeet.app",
|
|
# WeChat Reading (微信读书)
|
|
"微信读书": "com.tencent.weread",
|
|
"WechatReading": "com.tencent.weread",
|
|
"wechat_reading": "com.tencent.weread",
|
|
"wechatreading": "com.tencent.weread",
|
|
"weread": "com.tencent.weread",
|
|
# X (Twitter)
|
|
"X": "com.twitter.android",
|
|
"x": "com.twitter.android",
|
|
"Twitter": "com.twitter.android",
|
|
"twitter": "com.twitter.android",
|
|
# Weather (天气)
|
|
"天气": "com.miui.weather2",
|
|
"Weather": "com.miui.weather2",
|
|
"weather": "com.miui.weather2",
|
|
# Notes (备忘录)
|
|
"备忘录": "com.miui.notes",
|
|
"Notes": "com.miui.notes",
|
|
"notes": "com.miui.notes",
|
|
# Gallery (相册)
|
|
"相册": "com.miui.gallery",
|
|
"Gallery": "com.miui.gallery",
|
|
"gallery": "com.miui.gallery",
|
|
# FileManager (文件管理)
|
|
"文件管理": "com.android.fileexplorer",
|
|
"FileManager": "com.android.fileexplorer",
|
|
"file_manager": "com.android.fileexplorer",
|
|
"filemanager": "com.android.fileexplorer",
|
|
"Files": "com.android.fileexplorer",
|
|
# Calculator (计算器)
|
|
"计算器": "com.miui.calculator",
|
|
"Calculator": "com.miui.calculator",
|
|
"calculator": "com.miui.calculator",
|
|
# Browser (浏览器)
|
|
"浏览器": "com.android.browser",
|
|
"Browser": "com.android.browser",
|
|
"browser": "com.android.browser",
|
|
# Calendar (日历)
|
|
"日历": "com.android.calendar",
|
|
"Calendar": "com.android.calendar",
|
|
"calendar": "com.android.calendar",
|
|
# SMS (短信)
|
|
"短信": "com.android.mms",
|
|
"Messages": "com.android.mms",
|
|
"sms": "com.android.mms",
|
|
|
|
# ==================== Other Common Apps ====================
|
|
# Social & Messaging
|
|
"QQ": "com.tencent.mobileqq",
|
|
"微博": "com.sina.weibo",
|
|
"weibo": "com.sina.weibo",
|
|
"Telegram": "org.telegram.messenger",
|
|
"WhatsApp": "com.whatsapp",
|
|
# E-commerce
|
|
"淘宝": "com.taobao.taobao",
|
|
"taobao": "com.taobao.taobao",
|
|
"京东": "com.jingdong.app.mall",
|
|
"jd": "com.jingdong.app.mall",
|
|
"拼多多": "com.xunmeng.pinduoduo",
|
|
"pinduoduo": "com.xunmeng.pinduoduo",
|
|
"Temu": "com.einnovation.temu",
|
|
"eBay": "com.ebay.mobile",
|
|
"ebay": "com.ebay.mobile",
|
|
# Lifestyle & Social
|
|
"豆瓣": "com.douban.frodo",
|
|
"douban": "com.douban.frodo",
|
|
"知乎": "com.zhihu.android",
|
|
"zhihu": "com.zhihu.android",
|
|
"Reddit": "com.reddit.frontpage",
|
|
"Quora": "com.quora.android",
|
|
# Maps & Navigation
|
|
"百度地图": "com.baidu.BaiduMap",
|
|
"baidumap": "com.baidu.BaiduMap",
|
|
"Google Maps": "com.google.android.apps.maps",
|
|
# Food & Services
|
|
"美团": "com.sankuai.meituan",
|
|
"meituan": "com.sankuai.meituan",
|
|
"大众点评": "com.dianping.v1",
|
|
"饿了么": "me.ele",
|
|
"eleme": "me.ele",
|
|
"肯德基": "com.yek.android.kfc.activitys",
|
|
"McDonald": "com.mcdonalds.app",
|
|
# Travel
|
|
"携程": "ctrip.android.view",
|
|
"ctrip": "ctrip.android.view",
|
|
"铁路12306": "com.MobileTicket",
|
|
"12306": "com.MobileTicket",
|
|
"railway12306": "com.MobileTicket",
|
|
"去哪儿": "com.Qunar",
|
|
"滴滴出行": "com.sdu.didi.psnger",
|
|
"didi": "com.sdu.didi.psnger",
|
|
"Booking": "com.booking",
|
|
"Expedia": "com.expedia.bookings",
|
|
# Video & Entertainment
|
|
"抖音": "com.ss.android.ugc.aweme",
|
|
"douyin": "com.ss.android.ugc.aweme",
|
|
"TikTok": "com.zhiliaoapp.musically",
|
|
"tiktok": "com.zhiliaoapp.musically",
|
|
"快手": "com.smile.gifmaker",
|
|
"kuaishou": "com.smile.gifmaker",
|
|
"腾讯视频": "com.tencent.qqlive",
|
|
"爱奇艺": "com.qiyi.video",
|
|
"iqiyi": "com.qiyi.video",
|
|
"优酷视频": "com.youku.phone",
|
|
"youku": "com.youku.phone",
|
|
# Music & Audio
|
|
"网易云音乐": "com.netease.cloudmusic",
|
|
"netease_music": "com.netease.cloudmusic",
|
|
"喜马拉雅": "com.ximalaya.ting.android",
|
|
"ximalaya": "com.ximalaya.ting.android",
|
|
# Productivity
|
|
"飞书": "com.ss.android.lark",
|
|
"feishu": "com.ss.android.lark",
|
|
"Gmail": "com.google.android.gm",
|
|
# Browser
|
|
"Chrome": "com.android.chrome",
|
|
"chrome": "com.android.chrome",
|
|
# System
|
|
"Settings": "com.android.settings",
|
|
"settings": "com.android.settings",
|
|
"设置": "com.android.settings",
|
|
"Clock": "com.android.deskclock",
|
|
"Contacts": "com.android.contacts",
|
|
"Google Play Store": "com.android.vending",
|
|
# AI & Tools
|
|
"豆包": "com.larus.nova",
|
|
"doubao": "com.larus.nova",
|
|
"Duolingo": "com.duolingo",
|
|
# Reading
|
|
"番茄小说": "com.dragon.read",
|
|
# News
|
|
"腾讯新闻": "com.tencent.news",
|
|
"今日头条": "com.ss.android.article.news",
|
|
"toutiao": "com.ss.android.article.news",
|
|
}
|
|
|
|
|
|
def _package_to_app_name(package: str) -> Optional[str]:
|
|
"""Convert package name to app display name."""
|
|
for name, pkg in APP_PACKAGES.items():
|
|
if pkg == package or package.startswith(pkg):
|
|
return name
|
|
return None
|
|
|
|
|
|
class ActionHandler:
|
|
"""Base class for action handlers (Real Device)."""
|
|
def __init__(self, env: "RealDeviceEnv"):
|
|
self.env = env
|
|
|
|
async def execute(self, action: Action) -> None:
|
|
raise NotImplementedError
|
|
|
|
|
|
class ClickHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
x, y = self.env._parse_point(action.data.get("point"))
|
|
await self.env._tap(x, y)
|
|
|
|
|
|
class TypeHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
value = str(action.data.get("value", ""))
|
|
point = action.data.get("point")
|
|
clear = bool(action.data.get("clear", False))
|
|
if point:
|
|
x, y = self.env._parse_point(point)
|
|
await self.env._tap(x, y)
|
|
if clear:
|
|
await self.env._clear_input()
|
|
await self.env._type_text(value)
|
|
|
|
|
|
class DoubleTapHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
x, y = self.env._parse_point(action.data.get("point"))
|
|
await self.env._tap(x, y)
|
|
await asyncio.sleep(0.08)
|
|
await self.env._tap(x, y)
|
|
|
|
|
|
class LongPressHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
x, y = self.env._parse_point(action.data.get("point"))
|
|
duration = int(action.data.get("duration", 800))
|
|
await self.env._long_press(x, y, duration_ms=duration)
|
|
|
|
|
|
class SwipeHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
x1, y1 = self.env._parse_point(action.data.get("point1"))
|
|
x2, y2 = self.env._parse_point(action.data.get("point2"))
|
|
duration = int(action.data.get("duration", 300))
|
|
await self.env._swipe(x1, y1, x2, y2, duration_ms=duration)
|
|
|
|
|
|
class BackHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
await self.env._press_key("KEYCODE_BACK")
|
|
|
|
|
|
class HomeHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
await self.env._press_key("KEYCODE_HOME")
|
|
|
|
|
|
class RecentHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
await self.env._press_key("KEYCODE_APP_SWITCH")
|
|
|
|
|
|
class EnterHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
await self.env._press_key("KEYCODE_ENTER")
|
|
|
|
|
|
class DragHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
x1, y1 = self.env._parse_point(action.data.get("point1"))
|
|
x2, y2 = self.env._parse_point(action.data.get("point2"))
|
|
duration = int(action.data.get("duration", 2000))
|
|
await self.env._swipe(x1, y1, x2, y2, duration_ms=duration)
|
|
|
|
|
|
class WaitHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
secs = float(action.data.get("value", 1.0))
|
|
await asyncio.sleep(max(0.0, secs))
|
|
|
|
|
|
class AwakeHandler(ActionHandler):
|
|
async def execute(self, action: Action) -> None:
|
|
app_name = str(action.data.get("value", "")).strip()
|
|
if app_name:
|
|
await self.env._launch_app(app_name)
|
|
|
|
|
|
class RealDeviceEnv(BaseMobileEnv):
|
|
"""
|
|
ADB-based real Android device environment.
|
|
|
|
This is a lightweight implementation for real device support.
|
|
|
|
Limitations:
|
|
- Text Input: Uses YADB for Chinese/Unicode input (auto-installed on first run).
|
|
Falls back to `adb shell input text` for ASCII-only if YADB fails.
|
|
- Observation: Visual only (screenshot). No XML/ViewHierarchy dumping.
|
|
- App Launch: Basic `monkey` launch.
|
|
- State injection: Not supported (no set_state, no answer_sheet app).
|
|
|
|
Example usage (once implemented):
|
|
env = RealDeviceEnv(
|
|
device_serial="emulator-5554", # or real device serial
|
|
adb_path="~/Android/Sdk/platform-tools/adb",
|
|
)
|
|
env.start()
|
|
obs = env.reset(task)
|
|
...
|
|
"""
|
|
|
|
# No JSON state mutation on a real device — grounded-mode answer_sheet
|
|
# injection and similar state-seeding hooks must be skipped.
|
|
supports_state_injection: bool = False
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
device_serial: Optional[str] = None,
|
|
adb_path: str = "adb",
|
|
physical_size: Tuple[int, int] = (1080, 2400),
|
|
coord_space: str = "norm_0_1000",
|
|
delay_after_action: float = 1.0,
|
|
):
|
|
"""
|
|
Initialize RealDeviceEnv.
|
|
|
|
Args:
|
|
device_serial: Device serial number (from `adb devices`)
|
|
adb_path: Path to adb executable
|
|
physical_size: Device screen size (width, height)
|
|
coord_space: Coordinate space ("norm_0_1000", "norm_0_1", or "physical")
|
|
delay_after_action: Delay after each action in seconds
|
|
"""
|
|
self.device_serial = device_serial
|
|
self.adb_path = adb_path
|
|
self.physical_width, self.physical_height = physical_size
|
|
self.coord_space = coord_space
|
|
self.delay_after_action = delay_after_action
|
|
|
|
self._task: Optional[BaseTask] = None
|
|
self._step_count = 0
|
|
self._done = False
|
|
self._agent_message: Optional[str] = None
|
|
self._agent_answer: Optional[str] = None
|
|
# package -> "package/MainActivity", resolved lazily via pm resolve-activity.
|
|
# Empty string = resolution failed previously (skip and fall back to monkey).
|
|
self._main_activity_cache: dict[str, str] = {}
|
|
|
|
from bench_env.env.stopwatch import StopWatch
|
|
self.stopwatch = StopWatch()
|
|
|
|
# Initialize handlers
|
|
self._handlers: dict[ActionType, ActionHandler] = {
|
|
ActionType.CLICK: ClickHandler(self),
|
|
ActionType.DOUBLE_TAP: DoubleTapHandler(self),
|
|
ActionType.LONG_PRESS: LongPressHandler(self),
|
|
ActionType.TYPE: TypeHandler(self),
|
|
ActionType.SWIPE: SwipeHandler(self),
|
|
ActionType.DRAG: DragHandler(self),
|
|
ActionType.BACK: BackHandler(self),
|
|
ActionType.HOME: HomeHandler(self),
|
|
ActionType.RECENT: RecentHandler(self),
|
|
ActionType.ENTER: EnterHandler(self),
|
|
ActionType.WAIT: WaitHandler(self),
|
|
ActionType.AWAKE: AwakeHandler(self),
|
|
}
|
|
|
|
async def start(self) -> "RealDeviceEnv":
|
|
"""Connect to device and initialize required tools."""
|
|
# Check connectivity
|
|
out = await self._adb("shell", "getprop", "ro.product.model")
|
|
if not out:
|
|
raise RuntimeError(f"Failed to connect to device {self.device_serial}")
|
|
logger.info(f"Connected to {out.strip()}")
|
|
|
|
# Initialize YADB for Chinese text input
|
|
await self._init_yadb()
|
|
|
|
return self
|
|
|
|
async def _init_yadb(self) -> None:
|
|
"""
|
|
Initialize YADB (Yet Another Debug Bridge) for Chinese text input.
|
|
|
|
Checks if yadb is installed on device, if not, pushes it from local project.
|
|
"""
|
|
logger.debug("Checking YADB installation...")
|
|
|
|
# Check if yadb exists and has correct md5
|
|
YADB_MD5 = "29a0cd3b3adea92350dd5a25594593df"
|
|
result = await self._adb("shell", "md5sum", "/data/local/tmp/yadb")
|
|
|
|
if YADB_MD5 in result:
|
|
logger.info("YADB is already installed on the device")
|
|
return
|
|
|
|
logger.info("YADB not found or outdated on device, need to install...")
|
|
|
|
# Find yadb file in project
|
|
import os
|
|
bench_env_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
project_root = os.path.dirname(bench_env_dir)
|
|
yadb_paths = [
|
|
os.path.join(bench_env_dir, "yadb"), # bench_env/yadb (preferred)
|
|
os.path.join(project_root, "gelab-zero", "yadb"),
|
|
os.path.join(project_root, "yadb"),
|
|
]
|
|
|
|
yadb_path = None
|
|
for path in yadb_paths:
|
|
if os.path.exists(path):
|
|
yadb_path = path
|
|
logger.debug(f"Found YADB at: {yadb_path}")
|
|
break
|
|
|
|
if not yadb_path:
|
|
logger.warning("YADB not found in any of the expected paths:")
|
|
for path in yadb_paths:
|
|
logger.warning(f" - {path}")
|
|
logger.warning("Chinese text input may not work. "
|
|
"Please manually push yadb to /data/local/tmp/yadb")
|
|
return
|
|
|
|
# Push yadb to device
|
|
logger.info(f"Pushing YADB from {yadb_path} to device...")
|
|
push_result = await self._adb_push(yadb_path, "/data/local/tmp/yadb")
|
|
if not push_result:
|
|
logger.error("Failed to push YADB to device")
|
|
return
|
|
logger.info(f"YADB pushed: {push_result.strip()}")
|
|
|
|
# Set executable permission
|
|
logger.debug("Setting YADB executable permission...")
|
|
chmod_result = await self._adb("shell", "chmod", "+x", "/data/local/tmp/yadb")
|
|
|
|
# Verify installation
|
|
verify_result = await self._adb("shell", "md5sum", "/data/local/tmp/yadb")
|
|
if YADB_MD5 in verify_result:
|
|
logger.info("YADB installed and verified successfully")
|
|
else:
|
|
logger.warning(f"YADB installed but MD5 mismatch. Expected: {YADB_MD5}, Got: {verify_result.strip()}")
|
|
|
|
async def _adb_push(self, local_path: str, remote_path: str) -> str:
|
|
"""Push a file to the device."""
|
|
cmd = [self.adb_path]
|
|
if self.device_serial:
|
|
cmd.extend(["-s", self.device_serial])
|
|
cmd.extend(["push", local_path, remote_path])
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE
|
|
)
|
|
stdout, stderr = await proc.communicate()
|
|
if proc.returncode != 0:
|
|
logger.error(f"[ADB Push Error] {stderr.decode()}")
|
|
return ""
|
|
# Modern `adb push` writes progress / "1 file pushed" to stderr and
|
|
# leaves stdout empty; returning stdout here made callers that check
|
|
# `if not push_result:` treat a successful push as a failure. Return
|
|
# a non-empty sentinel on success and log the stderr diagnostics.
|
|
stderr_text = stderr.decode().strip()
|
|
if stderr_text:
|
|
logger.debug(f"[ADB Push] {stderr_text}")
|
|
return "ok"
|
|
|
|
async def close(self) -> None:
|
|
"""Disconnect from device."""
|
|
pass
|
|
|
|
async def reset(self, app_ids: list[str] | None = None) -> None:
|
|
"""Reset for a new episode.
|
|
|
|
`app_ids` is accepted for signature parity with MobileGymEnv but
|
|
ignored: on real devices we always force-stop every package listed in
|
|
APP_PACKAGES so the next episode starts from a fully clean background,
|
|
regardless of which apps the task declares.
|
|
"""
|
|
self._step_count = 0
|
|
self._done = False
|
|
self._agent_message = None
|
|
self._agent_answer = None
|
|
|
|
await self._press_key("KEYCODE_HOME")
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Force-stop every package listed in APP_PACKAGES so each episode starts from
|
|
# a clean background. We only touch apps we explicitly benchmark — system
|
|
# components (Launcher, IME, SystemUI) are left untouched.
|
|
for pkg in {p for p in APP_PACKAGES.values() if "." in p}:
|
|
await self._adb("shell", "am", "force-stop", pkg)
|
|
await asyncio.sleep(0.3)
|
|
|
|
# Second HOME press: most launchers scroll back to the first
|
|
# page on a second HOME while already on the launcher.
|
|
await self._press_key("KEYCODE_HOME")
|
|
await asyncio.sleep(0.1)
|
|
|
|
async def step(self, action: Action) -> StepResult:
|
|
self._step_count += 1
|
|
action_type = action.action_type
|
|
|
|
if action_type == ActionType.ANSWER:
|
|
value = str(action.data.get("value", ""))
|
|
self._agent_answer = value
|
|
logger.info(f"ANSWER: {value}")
|
|
return StepResult(
|
|
await self._get_observation(),
|
|
False,
|
|
{"action_type": ActionType.ANSWER, "answer": value},
|
|
)
|
|
|
|
if action_type == ActionType.COMPLETE:
|
|
self._done = True
|
|
self._agent_message = action.data.get("return", "")
|
|
return StepResult(await self._get_observation(), True,
|
|
{"stop_reason": ActionType.COMPLETE, "message": self._agent_message})
|
|
|
|
if action_type == ActionType.ABORT:
|
|
self._done = True
|
|
self._agent_message = action.data.get("value", "")
|
|
return StepResult(await self._get_observation(), True,
|
|
{"stop_reason": ActionType.ABORT, "message": self._agent_message})
|
|
|
|
handler = self._handlers.get(action_type)
|
|
if handler:
|
|
await handler.execute(action)
|
|
else:
|
|
logger.warning(f"Unknown action: {action_type}")
|
|
|
|
await asyncio.sleep(self.delay_after_action)
|
|
return StepResult(await self._get_observation(), False, {})
|
|
|
|
async def get_observation(self) -> Observation:
|
|
"""Public observation API (avoid calling private _get_observation)."""
|
|
return await self._get_observation()
|
|
|
|
async def get_state(self, required_apps: list[str] | None = None) -> dict[str, Any]:
|
|
"""
|
|
Get current environment state.
|
|
|
|
NOTE: This lightweight real-device env is visual-only; structured app/os
|
|
state is not available yet, so we return an empty dict.
|
|
"""
|
|
return {}
|
|
|
|
async def open_app(
|
|
self,
|
|
app_name: str,
|
|
timeout_ms: int = 8000,
|
|
wait_stable: bool = False,
|
|
) -> None:
|
|
"""
|
|
Open an application.
|
|
|
|
For real devices, `app_name` is treated as a package name (or "package/activity").
|
|
|
|
Args:
|
|
app_name: App ID or package name
|
|
timeout_ms: Timeout (ignored for ADB)
|
|
wait_stable: Wait for app to stabilize (for real devices, we just add a delay)
|
|
"""
|
|
# Ignore timeout_ms for now; ADB calls are best-effort.
|
|
if not app_name:
|
|
return
|
|
if "/" in app_name:
|
|
pkg, act = app_name.split("/", 1)
|
|
await self._launch_app(pkg, act)
|
|
else:
|
|
await self._launch_app(app_name)
|
|
|
|
# For real devices, wait a bit for app to stabilize
|
|
if wait_stable:
|
|
await asyncio.sleep(2.0)
|
|
|
|
async def go_home(self) -> None:
|
|
"""Press HOME key to return to launcher."""
|
|
await self._press_key("KEYCODE_HOME")
|
|
await asyncio.sleep(1.0)
|
|
|
|
@property
|
|
def agent_message(self) -> Optional[str]:
|
|
return self._agent_message
|
|
|
|
@property
|
|
def agent_answer(self) -> Optional[str]:
|
|
return self._agent_answer
|
|
|
|
def get_device_size(self) -> Tuple[int, int]:
|
|
return self.physical_width, self.physical_height
|
|
|
|
# ==================== ADB Command Helpers ====================
|
|
|
|
async def _adb(self, *args: str) -> str:
|
|
"""Execute ADB command asynchronously."""
|
|
cmd = [self.adb_path]
|
|
if self.device_serial:
|
|
cmd.extend(["-s", self.device_serial])
|
|
cmd.extend(args)
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE
|
|
)
|
|
stdout, stderr = await proc.communicate()
|
|
if proc.returncode != 0:
|
|
logger.error(f"[ADB Error] cmd={cmd} err={stderr.decode()}")
|
|
return ""
|
|
return stdout.decode()
|
|
|
|
async def _get_observation(self) -> Observation:
|
|
png_bytes = await self._screenshot()
|
|
|
|
# Get current app info
|
|
current_app = await self._get_current_app()
|
|
|
|
# NOTE: XML dump / ViewHierarchy is not implemented in this lightweight version.
|
|
# This environment currently supports VLM-based agents (Visual Only).
|
|
return Observation(
|
|
screenshot_bytes=png_bytes,
|
|
route={"app": current_app} if current_app else {},
|
|
state={},
|
|
step_idx=self._step_count,
|
|
)
|
|
|
|
async def _screenshot(self) -> bytes:
|
|
"""Capture screenshot via ADB."""
|
|
# Use exec-out screencap -p for raw PNG stream (faster than shell)
|
|
cmd = [self.adb_path]
|
|
if self.device_serial:
|
|
cmd.extend(["-s", self.device_serial])
|
|
cmd.extend(["exec-out", "screencap", "-p"])
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE
|
|
)
|
|
stdout, stderr = await proc.communicate()
|
|
return stdout
|
|
|
|
def _parse_point(self, point: Any) -> Tuple[int, int]:
|
|
if point is None:
|
|
return self.physical_width // 2, self.physical_height // 2
|
|
if isinstance(point, (list, tuple)) and len(point) >= 2:
|
|
x, y = float(point[0]), float(point[1])
|
|
else:
|
|
return self.physical_width // 2, self.physical_height // 2
|
|
|
|
if self.coord_space == "norm_0_1000":
|
|
x = x / 1000.0 * self.physical_width
|
|
y = y / 1000.0 * self.physical_height
|
|
elif self.coord_space == "norm_0_1":
|
|
x = x * self.physical_width
|
|
y = y * self.physical_height
|
|
|
|
return int(x), int(y)
|
|
|
|
async def _tap(self, x: int, y: int) -> None:
|
|
await self._adb("shell", "input", "tap", str(x), str(y))
|
|
|
|
async def _long_press(self, x: int, y: int, duration_ms: int = 800) -> None:
|
|
await self._adb("shell", "input", "swipe", str(x), str(y), str(x), str(y), str(duration_ms))
|
|
|
|
async def _clear_input(self) -> None:
|
|
"""Clear current input field via Ctrl+A then Delete."""
|
|
await self._adb("shell", "input", "keyevent", "KEYCODE_MOVE_HOME")
|
|
await self._adb("shell", "input", "keyevent", "--longpress", "KEYCODE_SHIFT_LEFT", "KEYCODE_MOVE_END")
|
|
await self._adb("shell", "input", "keyevent", "KEYCODE_DEL")
|
|
|
|
async def _swipe(self, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300) -> None:
|
|
await self._adb("shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(duration_ms))
|
|
|
|
async def _type_text(self, text: str) -> None:
|
|
"""
|
|
Type text into the currently focused input field.
|
|
|
|
Uses YADB for all text input (supports Chinese and Unicode).
|
|
Falls back to basic `adb shell input text` for ASCII-only if YADB fails.
|
|
"""
|
|
logger.debug(f"Typing text: {text[:30]}{'...' if len(text) > 30 else ''}")
|
|
|
|
# Always try YADB first (supports both ASCII and Unicode)
|
|
success = await self._type_via_yadb(text)
|
|
|
|
if success:
|
|
logger.debug("Text input via YADB successful")
|
|
else:
|
|
# Fallback to basic input for ASCII text
|
|
is_ascii = all(ord(c) < 128 for c in text)
|
|
if is_ascii:
|
|
logger.debug("YADB failed, falling back to basic adb input text")
|
|
escaped = shlex.quote(text)
|
|
await self._adb("shell", "input", "text", escaped)
|
|
else:
|
|
logger.warning(f"Failed to input non-ASCII text (YADB not available): {text[:20]}...")
|
|
|
|
async def _type_via_yadb(self, text: str) -> bool:
|
|
"""
|
|
Type text using YADB (Yet Another Debug Bridge).
|
|
|
|
Supports both ASCII and Chinese/Unicode text.
|
|
Requires yadb to be pushed to /data/local/tmp/yadb on the device.
|
|
"""
|
|
try:
|
|
# adb shell 会在设备端再次解析命令;这里必须保留空格为同一个参数。
|
|
shell_text = shlex.quote(text)
|
|
result = await self._adb(
|
|
"shell",
|
|
"app_process",
|
|
"-Djava.class.path=/data/local/tmp/yadb",
|
|
"/data/local/tmp",
|
|
"com.ysbing.yadb.Main",
|
|
"-keyboard",
|
|
shell_text
|
|
)
|
|
# YADB returns empty on success, check for errors
|
|
if "error" in result.lower() or "exception" in result.lower():
|
|
logger.debug(f"YADB returned error: {result}")
|
|
return False
|
|
return True
|
|
except Exception as e:
|
|
logger.debug(f"YADB input exception: {e}")
|
|
return False
|
|
|
|
async def _press_key(self, keycode: str) -> None:
|
|
await self._adb("shell", "input", "keyevent", keycode)
|
|
|
|
async def _launch_app(self, app_or_package: str, activity: Optional[str] = None) -> None:
|
|
"""
|
|
Launch an app by name or package.
|
|
|
|
Args:
|
|
app_or_package: App name (e.g., "微信", "wechat") or package name (e.g., "com.tencent.mm")
|
|
activity: Optional activity name
|
|
"""
|
|
package = self._resolve_package(app_or_package)
|
|
|
|
if activity:
|
|
# Explicit activity requested — honor it verbatim.
|
|
await self._adb("shell", "am", "start", "-n", f"{package}/{activity}")
|
|
return
|
|
|
|
# Prefer `am start --activity-clear-task` so the previous Task/Recents entry
|
|
# is wiped and the app opens from its MainActivity — avoids state carry-over
|
|
# between episodes (e.g. resuming the last opened book in WeChat Reading).
|
|
#
|
|
# Pass `-a MAIN -c LAUNCHER` explicitly so entry activities that inspect the
|
|
# incoming intent (e.g. system Messaging's MmsTabActivity) treat this as a
|
|
# launcher click and land on the default home screen, instead of
|
|
# dispatching to a secondary flow (like "compose new message").
|
|
component = await self._resolve_main_activity(package)
|
|
if component:
|
|
await self._adb(
|
|
"shell", "am", "start",
|
|
"-a", "android.intent.action.MAIN",
|
|
"-c", "android.intent.category.LAUNCHER",
|
|
"--activity-clear-task",
|
|
"-n", component,
|
|
)
|
|
return
|
|
|
|
# Fallback: standard LAUNCHER intent via monkey (does NOT clear the task).
|
|
await self._adb(
|
|
"shell", "monkey", "-p", package,
|
|
"-c", "android.intent.category.LAUNCHER", "1",
|
|
)
|
|
|
|
async def _resolve_main_activity(self, package: str) -> str:
|
|
"""Resolve `package/MainActivity` via `cmd package resolve-activity`.
|
|
|
|
Returns the component string usable with `am start -n`, or empty string
|
|
if resolution fails (caller should fall back to monkey).
|
|
"""
|
|
if package in self._main_activity_cache:
|
|
return self._main_activity_cache[package]
|
|
|
|
output = await self._adb(
|
|
"shell", "cmd", "package", "resolve-activity",
|
|
"--brief", "-c", "android.intent.category.LAUNCHER", package,
|
|
)
|
|
component = ""
|
|
for line in output.splitlines():
|
|
line = line.strip()
|
|
# Skip the priority/preferredOrder header line — the component line
|
|
# contains a slash and no '=' assignments.
|
|
if "/" in line and "=" not in line and line.startswith(package + "/"):
|
|
component = line
|
|
break
|
|
self._main_activity_cache[package] = component
|
|
return component
|
|
|
|
def _resolve_package(self, app_or_package: str) -> str:
|
|
"""
|
|
Resolve app name to package name.
|
|
|
|
Checks APP_PACKAGES mapping first, otherwise returns the input as-is
|
|
(assuming it's already a package name).
|
|
"""
|
|
# Direct match in APP_PACKAGES
|
|
if app_or_package in APP_PACKAGES:
|
|
return APP_PACKAGES[app_or_package]
|
|
|
|
# Case-insensitive match
|
|
lower_name = app_or_package.lower()
|
|
for name, pkg in APP_PACKAGES.items():
|
|
if name.lower() == lower_name:
|
|
return pkg
|
|
|
|
# Check if it's already a package name (contains dots)
|
|
if "." in app_or_package:
|
|
return app_or_package
|
|
|
|
logger.warning(f"Unknown app '{app_or_package}', using as package name directly")
|
|
return app_or_package
|
|
|
|
async def _get_current_app(self) -> Optional[str]:
|
|
"""
|
|
Get the currently focused app name via ADB.
|
|
|
|
Uses `dumpsys window` to find mCurrentFocus/mFocusedApp,
|
|
then matches package name against APP_PACKAGES mapping.
|
|
|
|
Returns:
|
|
App display name (e.g., "微信") or None if not recognized.
|
|
"""
|
|
output = await self._adb("shell", "dumpsys", "window")
|
|
if not output:
|
|
return None
|
|
|
|
# Parse window focus info
|
|
for line in output.split("\n"):
|
|
if "mCurrentFocus" in line or "mFocusedApp" in line:
|
|
# Extract package name from line like:
|
|
# mCurrentFocus=Window{...com.tencent.mm/com.tencent.mm.ui.LauncherUI...}
|
|
for app_name, package in APP_PACKAGES.items():
|
|
if package in line:
|
|
return app_name
|
|
|
|
return None
|