Files
workphone-sdk/sdk/app/services/adb_device.py

591 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
工作手机SDK v3.0 - ADB设备控制服务
直接通过ADB命令控制设备兼容模拟器
"""
import asyncio
import subprocess
import base64
import logging
import shlex
import time
import io
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Any, List, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
_adb_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="adb")
import os as _os
_adb_env = _os.environ.copy()
if not _adb_env.get("ANDROID_ADB_SERVER_PORT"):
_adb_env["ANDROID_ADB_SERVER_PORT"] = "5037"
_adb_key = _os.path.expanduser("~/.android/adbkey")
if _os.path.exists(_adb_key):
_adb_env["ADB_VENDOR_KEYS"] = _adb_key
class ADBDevice:
"""ADB设备控制"""
def __init__(self, serial: str):
self.serial = serial
self.last_active = datetime.now()
def _adb(self, *args, timeout: int = 30) -> subprocess.CompletedProcess:
"""执行ADB命令"""
cmd = ["adb", "-s", self.serial] + list(args)
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=_adb_env)
def _shell(self, cmd: str, timeout: int = 30) -> str:
"""执行shell命令"""
result = self._adb("shell", cmd, timeout=timeout)
input_command = cmd.lstrip().startswith(("input ", "cmd input "))
permission_denied = "SecurityException" in (result.stderr or "") or "INJECT_EVENTS" in (result.stderr or "")
if input_command and (result.returncode != 0 or permission_denied):
root_result = self._adb("shell", f"su -c {shlex.quote(cmd)}", timeout=timeout)
if root_result.returncode == 0:
return root_result.stdout.strip()
logger.warning(
"ADB 输入注入失败Root 重试仍未通过: serial=%s cmd=%s stderr=%s",
self.serial,
cmd.split()[0:2],
(root_result.stderr or result.stderr or "")[:200],
)
return result.stdout.strip()
# ========== 设备信息 ==========
def get_info(self) -> Dict[str, Any]:
"""获取设备信息"""
try:
model = self._shell("getprop ro.product.model")
brand = self._shell("getprop ro.product.brand")
version = self._shell("getprop ro.build.version.release")
sdk = self._shell("getprop ro.build.version.sdk")
size = self._shell("wm size")
# 解析分辨率
width, height = 1080, 2400
if "Physical size:" in size:
dims = size.split("Physical size:")[-1].strip()
if "x" in dims:
width, height = map(int, dims.split("x"))
return {
"serial": self.serial,
"model": model or "Unknown",
"brand": brand or "Unknown",
"android_version": version or "Unknown",
"sdk_version": sdk or "Unknown",
"display": {
"width": width,
"height": height
}
}
except Exception as e:
logger.error(f"获取设备信息失败: {e}")
return {"serial": self.serial, "error": str(e)}
def is_online(self) -> bool:
"""检查设备是否在线"""
try:
result = subprocess.run(
["adb", "devices"],
capture_output=True,
text=True,
timeout=5,
env=_adb_env,
)
for line in result.stdout.strip().splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] == self.serial:
return parts[1] == "device"
return False
except:
return False
# ========== 基础操作 ==========
def _ensure_awake_unlocked(self) -> Dict[str, Any]:
"""截图或微信控制前的亮屏/解锁兜底,避免历史黑屏证据。"""
state = {
"display_power": "unknown",
"dreaming_lockscreen": "unknown",
"actions": [],
}
try:
power = self._shell("dumpsys power | grep -E 'Display Power|mWakefulness|mHoldingDisplaySuspendBlocker'", timeout=5)
window = self._shell("dumpsys window | grep -E 'mDreamingLockscreen|mShowingLockscreen|mCurrentFocus'", timeout=5)
state["display_power"] = power.replace("\n", " | ")[:500]
state["dreaming_lockscreen"] = window.replace("\n", " | ")[:500]
if "state=OFF" in power or "Asleep" in power or "mWakefulness=Asleep" in power:
self._shell("input keyevent KEYCODE_WAKEUP", timeout=3)
state["actions"].append("KEYCODE_WAKEUP")
time.sleep(0.6)
# best-effort 解锁;无密码/已授权设备可直接拉起,密码机不会绕过用户安全策略。
self._shell("wm dismiss-keyguard", timeout=3)
state["actions"].append("wm dismiss-keyguard")
time.sleep(0.3)
window2 = self._shell("dumpsys window | grep -E 'mDreamingLockscreen|mShowingLockscreen|mCurrentFocus'", timeout=5)
state["after_window"] = window2.replace("\n", " | ")[:500]
state["success"] = "mDreamingLockscreen=true" not in window2
except Exception as e:
state["success"] = False
state["error"] = str(e)
return state
def _png_brightness(self, png_bytes: bytes) -> Dict[str, Any]:
"""返回截图质量指标Pillow不可用时只返回文件大小。"""
metrics = {"size": len(png_bytes), "is_black": False, "mean_luma": None, "std_luma": None}
try:
from PIL import Image, ImageStat
img = Image.open(io.BytesIO(png_bytes)).convert("L")
stat = ImageStat.Stat(img)
mean_luma = float(stat.mean[0])
std_luma = float(stat.stddev[0])
metrics.update({"mean_luma": round(mean_luma, 2), "std_luma": round(std_luma, 2)})
metrics["is_black"] = mean_luma < 8 and std_luma < 4
except Exception as e:
metrics["quality_warning"] = f"brightness_check_failed: {e}"
return metrics
def screenshot(self) -> Dict[str, Any]:
"""截图:先亮屏/解锁,再检测黑屏,黑屏不再返回成功证据。"""
try:
wake_state = self._ensure_awake_unlocked()
result = subprocess.run(
["adb", "-s", self.serial, "exec-out", "screencap", "-p"],
capture_output=True,
timeout=8,
env=_adb_env,
)
if result.returncode == 0 and result.stdout and len(result.stdout) > 100:
quality = self._png_brightness(result.stdout)
if quality.get("is_black"):
return {
"code": 409,
"message": "截图为黑屏,已判定为无效证据",
"detail": {"wake_state": wake_state, "quality": quality},
}
b64 = base64.b64encode(result.stdout).decode('utf-8')
info = self.get_info()
return {
"code": 200,
"data": {
"base64": b64,
"width": info.get("display", {}).get("width", 1080),
"height": info.get("display", {}).get("height", 2400),
"size": len(result.stdout),
"format": "png",
"quality": quality,
"wake_state": wake_state,
}
}
return {"code": 500, "message": "截图失败", "detail": f"returncode={result.returncode}, stdout_len={len(result.stdout)}"}
except Exception as e:
return {"code": 500, "message": str(e)}
def click(self, x: int, y: int) -> Dict[str, Any]:
"""点击坐标:执行前亮屏/解锁,避免锁屏点击落空。"""
try:
self._ensure_awake_unlocked()
self._shell(f"input tap {x} {y}")
self.last_active = datetime.now()
return {"code": 200, "data": {"success": True, "x": x, "y": y}}
except Exception as e:
return {"code": 500, "message": str(e)}
def click_text(self, text: str, timeout: float = 15) -> Dict[str, Any]:
"""点击文字:先亮屏/解锁再通过UI树查找支持text/content-desc/模糊匹配。"""
try:
self._ensure_awake_unlocked()
import re
try:
self._shell("uiautomator dump /sdcard/ui_tree.xml", timeout=10)
xml = self._shell("cat /sdcard/ui_tree.xml", timeout=5)
except (subprocess.TimeoutExpired, Exception) as te:
return {"code": 408, "message": f"UI树获取超时跳过点击: {text}"}
if not xml or len(xml) < 50:
return {"code": 404, "message": f"UI树为空无法查找: {text}"}
# 1. 精确匹配 text 属性
pattern = f'text="{re.escape(text)}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"'
match = re.search(pattern, xml)
if match:
x1, y1, x2, y2 = map(int, match.groups())
return self.click((x1 + x2) // 2, (y1 + y2) // 2)
# 2. 精确匹配 content-desc 属性
pattern_desc = f'content-desc="{re.escape(text)}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"'
match = re.search(pattern_desc, xml)
if match:
x1, y1, x2, y2 = map(int, match.groups())
return self.click((x1 + x2) // 2, (y1 + y2) // 2)
# 3. 模糊匹配:包含目标文字
pattern_fuzzy = f'text="[^"]*{re.escape(text)}[^"]*"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"'
match = re.search(pattern_fuzzy, xml)
if match:
x1, y1, x2, y2 = map(int, match.groups())
return self.click((x1 + x2) // 2, (y1 + y2) // 2)
# 4. 不区分大小写匹配
pattern_ci = f'(?i)text="[^"]*{re.escape(text)}[^"]*"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"'
match = re.search(pattern_ci, xml)
if match:
x1, y1, x2, y2 = map(int, match.groups())
return self.click((x1 + x2) // 2, (y1 + y2) // 2)
return {"code": 404, "message": f"未找到文字: {text}"}
except subprocess.TimeoutExpired:
return {"code": 408, "message": f"操作超时: {text}"}
except Exception as e:
return {"code": 500, "message": str(e)}
def input_text(self, text: str, clear: bool = False) -> Dict[str, Any]:
"""输入文字:先亮屏/解锁,默认追加,不清除已有内容。"""
try:
self._ensure_awake_unlocked()
if clear:
# 全选并删除(合并为一条命令减少开销)
self._shell("input keyevent KEYCODE_CTRL_A && input keyevent KEYCODE_DEL", timeout=10)
escaped = text.replace(" ", "%s").replace("'", "\\'").replace('"', '\\"')
self._shell(f"input text '{escaped}'", timeout=15)
self.last_active = datetime.now()
return {"code": 200, "data": {"success": True, "text": text}}
except Exception as e:
return {"code": 500, "message": str(e)}
def swipe(self, direction: str, scale: float = 0.5) -> Dict[str, Any]:
"""滑动:先亮屏/解锁,避免锁屏滑动落空。"""
try:
self._ensure_awake_unlocked()
info = self.get_info()
w = info.get("display", {}).get("width", 1080)
h = info.get("display", {}).get("height", 2400)
cx, cy = w // 2, h // 2
dist = int(h * scale * 0.4)
if direction == "up":
x1, y1, x2, y2 = cx, cy + dist, cx, cy - dist
elif direction == "down":
x1, y1, x2, y2 = cx, cy - dist, cx, cy + dist
elif direction == "left":
dist = int(w * scale * 0.4)
x1, y1, x2, y2 = cx + dist, cy, cx - dist, cy
elif direction == "right":
dist = int(w * scale * 0.4)
x1, y1, x2, y2 = cx - dist, cy, cx + dist, cy
else:
return {"code": 400, "message": f"无效方向: {direction}"}
self._shell(f"input swipe {x1} {y1} {x2} {y2} 500")
self.last_active = datetime.now()
return {"code": 200, "data": {"success": True, "direction": direction}}
except Exception as e:
return {"code": 500, "message": str(e)}
def press_key(self, key: str) -> Dict[str, Any]:
"""按键"""
key_map = {
"back": "KEYCODE_BACK",
"home": "KEYCODE_HOME",
"recent": "KEYCODE_APP_SWITCH",
"enter": "KEYCODE_ENTER",
"search": "KEYCODE_SEARCH",
"tab": "KEYCODE_TAB",
"del": "KEYCODE_DEL",
"delete": "KEYCODE_DEL",
"volume_up": "KEYCODE_VOLUME_UP",
"volume_down": "KEYCODE_VOLUME_DOWN",
"power": "KEYCODE_POWER",
"menu": "KEYCODE_MENU",
"space": "KEYCODE_SPACE",
}
keycode = key_map.get(key, key)
try:
self._shell(f"input keyevent {keycode}")
return {"code": 200, "data": {"success": True, "key": key}}
except Exception as e:
return {"code": 500, "message": str(e)}
# ========== APP操作 ==========
def start_app(self, package: str) -> Dict[str, Any]:
"""启动APP先亮屏/解锁,再 resolve launcher activity → am start。"""
try:
self._ensure_awake_unlocked()
activity = self._shell(
f"cmd package resolve-activity --brief {package} 2>/dev/null | tail -1",
timeout=10,
).strip()
if activity and "/" in activity:
result = self._shell(f"am start -n {activity}", timeout=10)
else:
result = self._shell(
f"monkey -p {package} -c android.intent.category.LAUNCHER 1",
timeout=20,
)
self.last_active = datetime.now()
return {"code": 200, "data": {"success": True, "package": package, "activity": activity}}
except Exception as e:
return {"code": 500, "message": str(e)}
def stop_app(self, package: str) -> Dict[str, Any]:
"""停止APP"""
try:
self._shell(f"am force-stop {package}")
return {"code": 200, "data": {"success": True, "package": package}}
except Exception as e:
return {"code": 500, "message": str(e)}
def current_app(self) -> Dict[str, Any]:
"""获取当前APP"""
try:
import re
output = self._shell("dumpsys activity activities | grep mResumedActivity", timeout=20)
match = re.search(r'(\w+\.\w+[\.\w]*)/[\.\w]+', output)
if not match:
output = self._shell("dumpsys window | grep -E 'mCurrentFocus'", timeout=20)
match = re.search(r'(\w+\.\w+[\.\w]*)/[\.\w]+', output)
package = match.group(1) if match else "unknown"
return {
"code": 200,
"data": {
"package": package,
"raw": output
}
}
except Exception as e:
return {"code": 500, "message": str(e)}
def installed_apps(self) -> Dict[str, Any]:
"""获取已安装APP"""
try:
output = self._shell("pm list packages -3")
packages = [
line.replace("package:", "")
for line in output.split("\n")
if line.startswith("package:")
]
return {"code": 200, "data": {"packages": packages}}
except Exception as e:
return {"code": 500, "message": str(e)}
# ========== UI树 ==========
def get_ui_tree(self) -> Dict[str, Any]:
"""获取UI树"""
try:
self._shell("uiautomator dump /sdcard/ui_tree.xml", timeout=15)
xml = self._shell("cat /sdcard/ui_tree.xml", timeout=10)
if not xml or len(xml) < 50:
return {"code": 500, "message": "获取UI树失败"}
xml_safe = xml.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
return {"code": 200, "data": {"xml": xml_safe, "length": len(xml)}}
except Exception as e:
return {"code": 500, "message": str(e)}
class ADBDeviceManager:
"""ADB设备管理器"""
_SCAN_CACHE_TTL = 15 # 远程 ADB 空扫描约 3 秒;缓存需覆盖页面与 BFF 连续轮询
def __init__(self):
self.devices: Dict[str, ADBDevice] = {}
self._deviceid_to_serial: Dict[str, str] = {}
self._scan_cache: Optional[List[str]] = None
self._scan_cache_time: float = 0
self._scan_all_cache: Optional[List[Dict[str, str]]] = None
self._scan_all_cache_time: float = 0
def scan_devices(self) -> List[str]:
"""扫描在线设备(仅已授权),带 TTL 缓存"""
now = time.time()
if self._scan_cache is not None and (now - self._scan_cache_time) < self._SCAN_CACHE_TTL:
return self._scan_cache
try:
result = subprocess.run(
["adb", "devices"],
capture_output=True,
text=True,
timeout=5,
env=_adb_env,
)
serials = []
for line in result.stdout.strip().split("\n")[1:]:
parts = line.split()
if len(parts) >= 2 and parts[1] == "device":
serial = parts[0]
serials.append(serial)
if serial not in self.devices:
self.devices[serial] = ADBDevice(serial)
self._build_deviceid_map(serial)
for serial in list(self.devices.keys()):
if serial not in serials:
self.devices.pop(serial, None)
self._scan_cache = serials
self._scan_cache_time = time.time()
return serials
except Exception as e:
logger.error(f"扫描设备失败: {e}")
return self._scan_cache or []
def scan_all_devices(self) -> List[Dict[str, str]]:
"""扫描全部 ADB 设备(含 unauthorized / offline带 TTL 缓存"""
now = time.time()
if self._scan_all_cache is not None and (now - self._scan_all_cache_time) < self._SCAN_CACHE_TTL:
return self._scan_all_cache
try:
result = subprocess.run(
["adb", "devices", "-l"],
capture_output=True, text=True, timeout=5, env=_adb_env,
)
devices = []
for line in result.stdout.strip().split("\n")[1:]:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 2:
continue
serial = parts[0]
adb_status = parts[1]
props = {}
for p in parts[2:]:
if ":" in p:
k, v = p.split(":", 1)
props[k] = v
entry = {
"serial": serial,
"adb_status": adb_status,
"model": props.get("model", ""),
"device": props.get("device", ""),
"transport_id": props.get("transport_id", ""),
"usb": props.get("usb", ""),
"product": props.get("product", ""),
"controllable": adb_status == "device",
}
if adb_status == "device" and serial not in self.devices:
self.devices[serial] = ADBDevice(serial)
self._build_deviceid_map(serial)
devices.append(entry)
self._scan_all_cache = devices
self._scan_all_cache_time = time.time()
return devices
except Exception as e:
logger.warning(f"扫描全部设备失败: {e}")
return self._scan_all_cache or []
def _build_deviceid_map(self, serial: str):
"""为 serial 构建 device_id→serial 映射android_id 哈希 + ro.serialno"""
import hashlib
try:
r = subprocess.run(
["adb", "-s", serial, "shell", "settings", "get", "secure", "android_id"],
capture_output=True, text=True, timeout=5, env=_adb_env,
)
android_id = r.stdout.strip()
if android_id and android_id != "null":
device_id = hashlib.md5(android_id.encode()).hexdigest()
self._deviceid_to_serial[device_id] = serial
except Exception:
pass
try:
r2 = subprocess.run(
["adb", "-s", serial, "shell", "getprop", "ro.serialno"],
capture_output=True, text=True, timeout=5, env=_adb_env,
)
ro_serial = (r2.stdout or "").strip()
if ro_serial:
self._deviceid_to_serial[ro_serial] = serial
except Exception:
pass
def register_mapping(self, device_id: str, serial: str):
"""注册 device_id → serial 映射(由 connection_priority 等外部服务调用)"""
if device_id and serial:
self._deviceid_to_serial[device_id] = serial
def resolve_serial(self, identifier: str) -> Optional[str]:
"""将 serial 或 device_id 解析为 ADB serial"""
self.scan_devices()
if identifier in self.devices:
return identifier
serial = self._deviceid_to_serial.get(identifier)
if serial in self.devices:
return serial
return None
def get_device(self, identifier: str) -> Optional[ADBDevice]:
"""获取设备(同时接受 serial 和 device_id"""
serial = self.resolve_serial(identifier)
if not serial:
return None
if serial not in self.devices:
if self._check_device(serial):
self.devices[serial] = ADBDevice(serial)
return self.devices.get(serial)
def _check_device(self, serial: str) -> bool:
"""检查设备是否存在"""
try:
result = subprocess.run(
["adb", "-s", serial, "get-state"],
capture_output=True,
text=True,
timeout=5,
env=_adb_env,
)
return result.stdout.strip() == "device"
except:
return False
def list_devices(self) -> List[Dict[str, Any]]:
"""列出所有设备"""
self.scan_devices()
devices = []
for serial, device in self.devices.items():
info = device.get_info()
info["status"] = "online" if device.is_online() else "offline"
devices.append(info)
return devices
# ── 异步包装(不阻塞 FastAPI 事件循环) ──
async def async_scan_devices(self) -> List[str]:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(_adb_executor, self.scan_devices)
async def async_scan_all_devices(self) -> List[Dict[str, str]]:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(_adb_executor, self.scan_all_devices)
async def async_list_devices(self) -> List[Dict[str, Any]]:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(_adb_executor, self.list_devices)
# 全局实例
adb_manager = ADBDeviceManager()