""" 工作手机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 = 3 # 缓存 3 秒,避免浏览器高频轮询打爆线程池 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()