""" Android Device Implementation Concrete implementation of AbstractDevice for Android devices. """ import logging import os import re import subprocess import sys import time from typing import Optional, Dict, Any, List from ...core.abstract_device import AbstractDevice from ...exceptions import FATAL_EXCEPTIONS # Lazy import flag - adapters will be imported in __init__ _adapters_imported = False ANDROID_LAUNCHER_PACKAGES = {"app.lawnchair"} ANDROID_APP_STORE_PACKAGES = {"com.android.vending"} class AndroidDevice(AbstractDevice): """ Android 设备的具体实现 继承自 AbstractDevice,实现所有 Android 特定的设备操作。 """ def __init__(self, device_serial=None, is_emulator=False, output_dir=None, app_path=None, cv_mode=False, grant_perm=False, telnet_auth_token=None, enable_accessibility_hard=False, humanoid=None, ignore_ad=False, mumu_manager_path=None, mumu_vm_index=2, **kwargs): """ 初始化 Android 设备连接 :param device_serial: 目标设备的序列号 :param is_emulator: 是否是模拟器 :param output_dir: 输出目录 :param app_path: APK 文件路径(被测应用) :param cv_mode: 是否启用 CV 模式 :param grant_perm: 是否自动授予权限 :param telnet_auth_token: Telnet 认证令牌 :param enable_accessibility_hard: 是否强制启用辅助功能 :param humanoid: Humanoid 服务地址 :param ignore_ad: 是否忽略广告 :param mumu_manager_path: MuMuManager.exe 路径(用于模拟器底层截图回退) :param mumu_vm_index: MuMu 虚拟机索引(默认 2) """ super().__init__(output_dir=output_dir) # 内部管理的 App 对象 self._app = None self.package_name = app_path # Reusing app_path argument as package_name for compatibility if passed positionally, but better explicitly rename in init if app_path: from .android_app import AndroidApp self._app = AndroidApp(package_name=app_path, output_dir=output_dir) # 设备标识 if device_serial is None: from .utils import get_available_devices all_devices = get_available_devices() if len(all_devices) == 0: self.logger.warning("ERROR: No device connected.") sys.exit(-1) device_serial = all_devices[0] if "emulator" in device_serial and not is_emulator: self.logger.warning("Seems like you are using an emulator. If so, please add is_emulator option.") self.serial = device_serial self.is_emulator = is_emulator self.cv_mode = cv_mode self.grant_perm = grant_perm self.enable_accessibility_hard = enable_accessibility_hard self.humanoid = humanoid self.ignore_ad = ignore_ad self.mumu_manager_path = mumu_manager_path self.mumu_vm_index = mumu_vm_index # 设备信息缓存 self.model_number = None self.sdk_version = None self.release_version = None self._used_ports = [] # CV 模式相关缓存 self.last_screenshot_hash = None self.last_views = None self.views_cache = {} # Cache for view trees: {hash: views} self._accessibility_fail_count = 0 # 连续获取views失败的计数 # 状态缓存(用于优化连续事件的状态获取) self._last_state = None # 延迟导入适配器以避免循环依赖 from .adapters.adb import ADB from .adapters.telnet import TelnetConsole from .adapters.droidbot_app import DroidBotAppConn from .adapters.minicap import Minicap from .adapters.logcat import Logcat from .adapters.user_input_monitor import UserInputMonitor from .adapters.process_monitor import ProcessMonitor from .adapters.droidbot_ime import DroidBotIme # 初始化适配器 self.adb = ADB(device=self) self.telnet = TelnetConsole(device=self, auth_token=telnet_auth_token) self.droidbot_app = DroidBotAppConn(device=self) self.minicap = Minicap(device=self) self.logcat = Logcat(device=self) self.user_input_monitor = UserInputMonitor(device=self) self.process_monitor = ProcessMonitor(device=self) self.droidbot_ime = DroidBotIme(device=self) # 适配器启用状态 self.adapters = { self.adb: True, self.telnet: False, self.droidbot_app: True, self.minicap: True, self.logcat: True, self.user_input_monitor: True, self.process_monitor: True, self.droidbot_ime: True } # 模拟器不支持 minicap if self.is_emulator: self.logger.info("disable minicap on emulator") self.adapters[self.minicap] = False # SDK >= 32 不支持 minicap if self.get_sdk_version() >= 32: self.logger.info("disable minicap on sdk >= 32") self.adapters[self.minicap] = False # CV 模式预初始化 if self.cv_mode: self.logger.info("CV模式已启用,正在预初始化OmniParser模型...") try: from DroidBot.cv import cv model_manager = cv.get_model_manager() self.logger.info("OmniParser模型预初始化完成") except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.error(f"预初始化OmniParser模型失败: {e}") raise # CV模式初始化失败是致命的 # ==================== 平台信息 ==================== def get_platform_name(self) -> str: return "android" @property def captured_traffic_dir(self) -> str: """ 获取捕获的流量日志存储目录 """ return "/sdcard/Download/PCAPdroid/traffic/" # ==================== 连接管理 ==================== def set_up(self) -> None: """设置设备连接""" self.wait_for_device() # Explicitly set up adapters that require it if self.adapters.get(self.droidbot_app): self.droidbot_app.set_up() if self.adapters.get(self.minicap): self.minicap.set_up() if self.adapters.get(self.droidbot_ime): self.droidbot_ime.set_up() def connect(self) -> bool: """连接到设备""" for adapter in self.adapters: if self.adapters[adapter]: adapter.connect() self.get_sdk_version() self.get_release_version() self.get_display_info() self.unlock() self.check_connectivity() self.connected = True # Populating activities dynamically if self._app: self._app.populate_activities(self) return True def disconnect(self) -> None: """断开设备连接""" self.connected = False for adapter in self.adapters: if self.adapters[adapter]: adapter.disconnect() # if self.output_dir is not None: # temp_dir = os.path.join(self.output_dir, "temp") # if os.path.exists(temp_dir): # import shutil # shutil.rmtree(temp_dir) def tear_down(self) -> None: """清理资源""" # Explicitly tear down adapters that require it if self.adapters.get(self.droidbot_app): self.droidbot_app.tear_down() if self.adapters.get(self.minicap): self.minicap.tear_down() if self.adapters.get(self.droidbot_ime): self.droidbot_ime.tear_down() def check_connectivity(self) -> bool: """检查连接状态""" all_connected = True for adapter in self.adapters: adapter_name = adapter.__class__.__name__ adapter_enabled = self.adapters[adapter] if not adapter_enabled: print("[CONNECTION] %s is not enabled." % adapter_name) else: if adapter.check_connectivity(): print("[CONNECTION] %s is enabled and connected." % adapter_name) else: print("[CONNECTION] %s is enabled but not connected." % adapter_name) all_connected = False return all_connected def wait_for_device(self) -> None: """等待设备启动完成""" self.logger.info("waiting for device") self.adb.run_cmd(["wait-for-device"]) # ==================== 状态获取 ==================== def check_network(self, host: str = "8.8.8.8") -> bool: """ 通过 ADB Ping 检查安卓设备内部网络是否通畅 """ err_msg = self.adb.run_cmd(['shell', 'ping', '-c', '1', '-W', '2', host]) if "1 received" in err_msg.lower() : return True else: return False def get_current_state(self) -> 'AndroidDeviceState': """获取当前设备状态""" self.logger.debug("getting current device state...") current_state = None try: from datetime import datetime tag = datetime.now().strftime("%Y-%m-%d_%H%M%S") views = self.get_views() foreground_activity = self.get_top_activity_name() activity_stack = self.get_current_activity_stack(top_activity=foreground_activity) # background_services = self.get_service_names() background_services = [] screenshot_path = self.take_screenshot(tag=tag) from .android_device_state import AndroidDeviceState current_state = AndroidDeviceState( self, views=views, foreground_activity=foreground_activity, activity_stack=activity_stack, background_services=background_services, tag=tag, screenshot_path=screenshot_path ) except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.error("exception in get_current_state: %s" % e) import traceback traceback.print_exc() return None if not current_state: self.logger.warning("Failed to get current state!") return current_state def get_display_info(self, refresh: bool = False) -> Dict[str, Any]: """获取显示信息""" if self.display_info is None or refresh: self.display_info = self.adb.get_display_info() return self.display_info # ==================== 屏幕操作 ==================== def take_screenshot(self, path: str = None, tag: str = None) -> str: """截取屏幕""" if self.output_dir is None and path is None: return None if tag is None: from datetime import datetime tag = datetime.now().strftime("%Y-%m-%d_%H%M%S") if path is not None: local_image_path = path else: local_image_dir = os.path.join(self.output_dir, "states") if not os.path.exists(local_image_dir): os.makedirs(local_image_dir) ext = ".jpg" if (self.adapters[self.minicap] and self.minicap.last_screen) else ".png" local_image_path = os.path.join(local_image_dir, "screen_%s%s" % (tag, ext)) # Ensure parent directory exists parent_dir = os.path.dirname(local_image_path) if parent_dir and not os.path.exists(parent_dir): os.makedirs(parent_dir) if self.adapters[self.minicap] and self.minicap.last_screen: with open(local_image_path, 'wb') as f: f.write(self.minicap.last_screen) return local_image_path else: remote_image_path = "/sdcard/screen_%s.png" % tag try: self.adb.shell_grep("screencap -p %s" % remote_image_path) self.pull_file(remote_image_path, local_image_path) self.adb.shell("rm %s" % remote_image_path) # 检查截图是否为空(部分应用限制截图会导致生成0KB的文件) if os.path.exists(local_image_path) and os.path.getsize(local_image_path) == 0: self.logger.warning("截图文件为0KB,可能被应用限制,尝试使用MuMu模拟器底层截图...") import time import glob import shutil start_time = time.time() if not self.mumu_manager_path or not os.path.exists(self.mumu_manager_path): self.logger.error("MuMuManager.exe 路径未配置或不存在,无法使用底层截图") else: mumu_cmd = f'"{self.mumu_manager_path}" control -v {self.mumu_vm_index} tool func -n screenshot' subprocess.run(mumu_cmd, shell=True, check=False) # 等待截图文件生成 time.sleep(1.5) mumu_screenshot_dir = os.path.join( os.path.expanduser("~"), "Documents", "MuMu共享文件夹", "Screenshots" ) if os.path.exists(mumu_screenshot_dir): files = glob.glob(os.path.join(mumu_screenshot_dir, "MuMu-*.png")) # 找到命令执行时间之后生成的最新文件(给2秒容错) recent_files = [f for f in files if os.path.getmtime(f) >= start_time - 2] if recent_files: newest_file = max(recent_files, key=os.path.getmtime) shutil.move(newest_file, local_image_path) self.logger.info(f"成功使用MuMu模拟器底层截图覆盖空文件: {newest_file} -> {local_image_path}") else: self.logger.error("未找到最近生成的MuMu底层截图文件") else: self.logger.error(f"MuMu截图目录不存在: {mumu_screenshot_dir}") except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.error(f"Failed to take screenshot: {e}") return None return local_image_path def unlock(self) -> None: """解锁屏幕""" self.adb.unlock() # ==================== 事件发送 ==================== def send_event(self, event) -> bool: """发送输入事件""" event.send(self) return True def view_touch(self, x: int, y: int) -> None: """触摸指定坐标""" self.adb.touch(x, y) def view_long_touch(self, x: int, y: int, duration: int = 2000) -> None: """长按指定坐标""" self.adb.long_touch(x, y, duration) def view_drag(self, start_xy: tuple, end_xy: tuple, duration: int) -> None: """拖拽操作""" self.adb.drag(start_xy, end_xy, duration) def view_set_text(self, text: str) -> None: """设置文本""" if self.droidbot_ime.connected: self.droidbot_ime.input_text(text=text, mode=0) else: self.logger.warning("`adb shell input text` doesn't support setting text, appending instead.") self.adb.type(text) def key_press(self, key_code: str) -> None: """按键操作""" self.adb.press(key_code) # ==================== 应用管理 ==================== @property def app_identifier(self) -> str: """获取被测应用的唯一标识符(package_name)""" if self._app: return self._app.package_name return "" def is_foreground(self) -> bool: """检查被测应用是否在前台""" if not self._app: return True # 没有指定应用时,总是返回 True current_package = self.get_current_package() return current_package == self._app.package_name def get_redirect_target_info(self) -> Optional[Dict[str, Optional[str]]]: """ 获取应用离开前台后的目标包名及分类 :return: { "target": 跳转目标包名或 None, "type": "app_store" | "launcher" | "other" } """ if not self._app: return None current_package = self.get_current_package() if current_package in ANDROID_APP_STORE_PACKAGES: target_type = "app_store" elif current_package in ANDROID_LAUNCHER_PACKAGES or current_package is None: target_type = "launcher" else: target_type = "other" return { "target": current_package, "type": target_type } def pull_back_to_app(self) -> bool: """将被测应用拉回前台""" if not self._app: return True # 没有指定应用时,无需拉回 package_name = self._app.package_name current_package = self.get_current_package() # 内部已经有重试机制 if current_package == package_name: return True if current_package is None: self.logger.error(f"【Pull Back】多次尝试后仍无法获取当前包名,判定为系统异常,返回失败") return False self.logger.info(f"【Pull Back】检测到当前处于 {current_package},正在拉回 {package_name}...") max_retries = 3 for retry_count in range(1, max_retries + 1): self.logger.info(f"【Pull Back】尝试第 {retry_count}/{max_retries} 次拉回...") try: self.adb.shell("input keyevent 4") time.sleep(0.5) monkey_cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1" self.adb.shell_grep(monkey_cmd) max_wait_time = 10 check_interval = 0.5 elapsed_time = 0 while elapsed_time < max_wait_time: time.sleep(check_interval) elapsed_time += check_interval verify_package = self.get_current_package() if verify_package == package_name: self.logger.info(f"【Pull Back】第 {retry_count} 次尝试成功 (耗时 {elapsed_time:.1f}s)") return True self.logger.warning(f"【Pull Back】第 {retry_count} 次尝试失败,当前包名: {verify_package}") except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.error(f"【Pull Back】第 {retry_count} 次尝试发生异常: {e}") return False self.logger.warning(f"【Pull Back】{max_retries} 次尝试都失败") return False def start_app(self) -> bool: """启动被测应用""" if not self._app: self.logger.warning("No app specified, cannot start") return False try: # 优先尝试使用 monkey 启动,因为它不需要知道主 Activity # -p 包名 # -c android.intent.category.LAUNCHER 模拟点击桌面图标启动 # 1 表示随机产生一个事件,这里其实就是触发启动 cmd = f"monkey -p {self._app.package_name} -c android.intent.category.LAUNCHER 1" self.adb.shell(cmd) time.sleep(15) # 检测是否为游戏应用(通过检查是否加载了 libunity.so) self._check_and_enable_cv_mode_if_game() return True except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.error(f"Failed to start app via monkey: {e}") return False def _check_and_enable_cv_mode_if_game(self): """ 检测应用是否为游戏(通过检查是否加载了 libunity.so 或 Activity 名称包含 unity)。 如果是游戏,则自动启用 CV 模式。 """ if not self._app: return try: package_name = self._app.package_name # 1. 检查 Activity 名称是否包含 unity 关键字 top_activity = self.get_top_activity_name() if top_activity and 'unity' in top_activity.lower(): self.logger.info(f"Detected game app (activity name contains 'unity': {top_activity}), enabling CV mode") self.cv_mode = True return # 2. 获取应用进程 PID(使用 shell_grep 支持管道符) pid_cmd = f"ps -ef | grep {package_name}" pid_output = self.adb.shell_grep(pid_cmd) if not pid_output: self.logger.debug(f"Could not find process for package: {package_name}") return # 解析 PID(ps -ef 输出格式中 PID 是第二列) pid = None for line in pid_output.strip().split('\n'): if package_name in line and 'grep' not in line: parts = line.split() if len(parts) >= 2: try: pid = parts[1] break except (IndexError, ValueError): continue if not pid: self.logger.debug(f"Could not parse PID for package: {package_name}") return self.logger.debug(f"Found PID {pid} for package {package_name}") # 3. 检查 /proc/pid/maps 中是否包含 libunity.so(使用 shell_grep 支持管道符) maps_cmd = f"su 0 cat /proc/{pid}/maps | grep libunity.so" maps_output = self.adb.shell_grep(maps_cmd) if maps_output and 'libunity.so' in maps_output: self.logger.info(f"Detected game app (libunity.so found in process maps), enabling CV mode") self.cv_mode = True else: self.logger.info(f"No libunity.so found, not a Unity game") except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.debug(f"Failed to check if app is a game: {e}") def install_app(self) -> bool: """安装被测应用 (We assume app is already installed)""" self.logger.info("Skipping install_app: Package-only mode assumes app is installed.") return True def uninstall_app(self) -> bool: """卸载被测应用 (We do not uninstall in Package-only mode to preserve environment)""" self.logger.info("Skipping uninstall_app: Package-only mode preserves app.") return True # ==================== Android 特定方法 ==================== def get_traffic_domains(self, remote_dir: str) -> Optional[str]: """ 获取最新的流量域名日志文件内容。 对应 TrafficMonitor 的需求。 """ try: # 1. ls -t to find latest file # Note: We use shell directly to use wildcard expansion and ls options cmd_find_latest = ["shell", "ls", "-t", os.path.join(remote_dir, "*.txt")] latest_file_path_out = self.adb.run_cmd(cmd_find_latest) if not latest_file_path_out: return None # Parse first line (ls -t output) latest_file = latest_file_path_out.split('\n')[0].strip() if not latest_file: return None # 2. tail -n 500 cmd_read = ["shell", "cat", latest_file] content = self.adb.run_cmd(cmd_read) return content except Exception as e: self.logger.error(f"Failed to get traffic domains: {e}") return None def is_traffic_capture_running(self) -> bool: """通过通知栏检查 PCAPDroid 是否正在运行""" try: output = self.adb.run_cmd([ "shell", "dumpsys", "notification", "--noredact" ]) if "com.emanuelef.remote_capture" in output and "Capture running" in output: return True return False except Exception: return False def restart_traffic_capture(self, package_name: str) -> None: """重启 PCAPDroid 抓包""" try: cmd = [ "shell", "am", "start", "-n", "com.emanuelef.remote_capture/com.emanuelef.remote_capture.activities.CaptureCtrl", "-e", "action", "start", "-e", "pcap_dump_mode", "pcap_file", "-e", "app_filter", package_name, "-e", "root_capture", "true" if self.is_emulator else "false", ] self.adb.run_cmd(cmd) time.sleep(5) except Exception as e: self.logger.error(f"Failed to restart traffic capture: {e}") def get_model_number(self) -> str: """获取设备型号""" if self.model_number is None: self.model_number = self.adb.get_model_number() return self.model_number def get_sdk_version(self) -> int: """获取 SDK 版本""" if self.sdk_version is None: self.sdk_version = self.adb.get_sdk_version() return self.sdk_version def get_release_version(self) -> str: """获取 Android 版本""" if self.release_version is None: self.release_version = self.adb.get_release_version() return self.release_version def get_top_activity_name(self) -> Optional[str]: """获取当前 Activity""" try: output = self.adb.shell("dumpsys activity activities") resumed_re = re.compile(r'mResumedActivity: ActivityRecord\{[a-f0-9]+\s+\S+\s+([^ ]+)\s+t(\d+)\}') m = resumed_re.search(output) if m: return m.group(1) focused_re = re.compile(r'mFocusedActivity: ActivityRecord\{[a-f0-9]+\s+\S+\s+([^ ]+)\s+t(\d+)\}') m = focused_re.search(output) if m: return m.group(1) activity_line_re = re.compile(r'\*\s*Hist\s*#\d+:\s*ActivityRecord\{[^ ]+\s*[^ ]+\s*([^ ]+)\s*t(\d+)}') m = activity_line_re.search(output) if m: return m.group(1) except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.error(f"Error getting top activity name: {e}") return None return None def get_current_package(self) -> Optional[str]: """ 获取当前前台包名 失败时自动透明重试(最多3次,每次间隔0.5秒) :return: 包名或 None """ max_attempts = 3 retry_delay = 0.5 # 秒 for attempt in range(max_attempts): try: # 方法1: 通过 dumpsys window 获取 mFocusedApp (最准确) app_out = self.adb.shell_grep("dumpsys window | grep mFocusedApp") if app_out: # findall 取最后一个非 null 的匹配(dumpsys 会输出多行历史) matches = re.findall(r'mFocusedApp=ActivityRecord\{[a-f0-9]+\s+\S+\s+([^/]+)/', app_out) if matches: pkg_name = matches[-1] if attempt > 0: self.logger.debug(f"【Package】第 {attempt + 1} 次尝试通过 mFocusedApp 获取到: {pkg_name}") return pkg_name # 方法2: 通过 dumpsys window 获取焦点窗口 focus_out = self.adb.shell_grep("dumpsys window | grep mCurrentFocus") if focus_out: matches = re.findall(r'Window\{[a-f0-9]+\s+\S+\s+([^/]+)/', focus_out) if matches: pkg_name = matches[-1] if attempt > 0: self.logger.debug(f"【Package】第 {attempt + 1} 次尝试通过 mCurrentFocus 获取到: {pkg_name}") return pkg_name # 方法3: 通过 dumpsys activity 获取 resumed activity resumed_out = self.adb.shell_grep("dumpsys activity activities | grep mResumedActivity") if resumed_out: matches = re.findall(r'\{[a-f0-9]+\s+\S+\s+([^/]+)/', resumed_out) if matches: pkg_name = matches[-1] if attempt > 0: self.logger.debug(f"【Package】第 {attempt + 1} 次尝试通过 mResumedActivity 获取到: {pkg_name}") return pkg_name # 三种方法都没获取到,且还有重试机会 if attempt < max_attempts - 1: self.logger.debug(f"【Package】第 {attempt + 1}/{max_attempts} 次尝试未获取到包名,{retry_delay}秒后重试...") time.sleep(retry_delay) except Exception as e: if attempt < max_attempts - 1: self.logger.debug(f"【Package】第 {attempt + 1}/{max_attempts} 次尝试异常: {e},{retry_delay}秒后重试...") time.sleep(retry_delay) else: self.logger.error(f"【Package】最终尝试异常: {e}") if isinstance(e, FATAL_EXCEPTIONS): raise return None self.logger.warning(f"【Package】{max_attempts} 次尝试后仍无法获取包名") return None def get_current_activity_stack(self, top_activity: Optional[str] = None) -> List[str]: """获取当前 Activity 栈""" task_to_activities = self.get_task_activities() if top_activity is None: top_activity = self.get_top_activity_name() if top_activity: for task_id in task_to_activities: activities = task_to_activities[task_id] if len(activities) > 0 and activities[0] == top_activity: return activities self.logger.warning("Unable to get current activity stack.") return [top_activity] else: return [] def get_task_activities(self) -> Dict[str, List[str]]: """获取任务和对应的 Activity""" task_to_activities = {} lines = self.adb.shell("dumpsys activity activities").splitlines() activity_line_re = re.compile(r'\*\s*Hist\s*#\d+:\s*ActivityRecord\{[^ ]+\s*[^ ]+\s*([^ ]+)\s*t(\d+)}') for line in lines: line = line.strip() task_match = re.match(r'^\s*Task\s*id\s*#(\d+)|^\s*Task\{\w+\s*#(\d+)', line) if task_match: task_id = task_match.group(1) or task_match.group(2) task_to_activities[task_id] = [] elif re.match(r'\*\s*Hist\s*#', line): m = activity_line_re.match(line) if m: activity = m.group(1) task_id = m.group(2) if task_id not in task_to_activities: task_to_activities[task_id] = [] task_to_activities[task_id].append(activity) return task_to_activities def get_service_names(self) -> List[str]: """获取运行中的服务""" services = [] dat = self.adb.shell('dumpsys activity services') lines = dat.splitlines() service_re = re.compile(r'^.+ServiceRecord{.+ ([A-Za-z0-9_.]+)/([A-Za-z0-9_.]+)') for line in lines: m = service_re.search(line) if m: package = m.group(1) service = m.group(2) services.append("%s/%s" % (package, service)) return services def get_views(self) -> Optional[List[Dict[str, Any]]]: """获取当前界面视图""" if self.cv_mode: return self._get_views_cv_mode() if self.droidbot_app and self.adapters[self.droidbot_app]: views = self.droidbot_app.get_views() if views: self._accessibility_fail_count = 0 return views else: self._accessibility_fail_count += 1 self.logger.warning(f"Failed to get views using Accessibility. (consecutive failures: {self._accessibility_fail_count})") if self._accessibility_fail_count >= 5: self.logger.warning("Accessibility failed 5 times consecutively, switching to CV mode.") self.cv_mode = True self._accessibility_fail_count = 0 return self._get_views_cv_mode() self.logger.warning("failed to get current views!") return None def _get_views_cv_mode(self) -> Optional[List[Dict[str, Any]]]: """CV 模式获取视图 - 使用统一的 ViewDict 格式""" try: from .adapters import cv from datetime import datetime tag = datetime.now().strftime("%Y-%m-%d_%H%M%S") temp_dir = os.path.join(self.output_dir, "temp") if self.output_dir else "/tmp" if not os.path.exists(temp_dir): os.makedirs(temp_dir) local_image_path = os.path.join(temp_dir, "cv_screen_%s.png" % tag) remote_image_path = "/sdcard/cv_screen_%s.png" % tag try: self.adb.shell_grep("screencap -p %s" % remote_image_path) except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.error(f"Failed to take screenshot: {e}") return None self.pull_file(remote_image_path, local_image_path) self.adb.shell("rm %s" % remote_image_path) img = cv.load_image_from_path(local_image_path) current_screenshot_hash = cv.calculate_dhash(img) # 检查缓存(仅使用模糊匹配) # 针对动态 UI 场景(如游戏),遍历缓存寻找相似度高的历史界面 # 即使 Hash 不同(微小变动),只要相似度高(>90%)也视为命中 best_match_views = None max_distance = len(current_screenshot_hash) * 4 # 遍历缓存进行模糊匹配 for cached_hash in self.views_cache: hamming_distance = cv.dhash_hamming_distance(current_screenshot_hash, cached_hash) similarity = 1.0 - (hamming_distance / max_distance) # 只要找到满足条件(>90%)的缓存,立即返回(贪婪策略) # 假设缓存中的界面都是“不同”的,一旦相似度极高,说明就是同一个界面 if similarity > 0.88: best_match_views = self.views_cache[cached_hash] break # Found a match, stop searching if best_match_views: self.logger.info(f"View cache hit! Similarity: {similarity:.2f}") self.last_screenshot_hash = current_screenshot_hash self.last_views = best_match_views try: os.remove(local_image_path) except: pass return best_match_views # find_views 现在直接返回 List[ViewDict] 格式 cv_views = cv.find_views(img) # 构建根视图 display_info = self.get_display_info() width = display_info.get('width', 1080) height = display_info.get('height', 1920) root_view = { "class_name": "CVViewRoot", "bounds": [[0, 0], [width, height]], "enabled": True, "visible": True, "clickable": False, "scrollable": False, "editable": False, "temp_id": 0, "children": [], "text": "", "source": "cv", "resource_id": "", "view_str": "cv_root", } # 重新分配 temp_id 并设置父子关系 views = [root_view] for idx, view in enumerate(cv_views): view["temp_id"] = idx + 1 view["parent"] = 0 views.append(view) root_view["children"] = list(range(1, len(views))) self.last_screenshot_hash = current_screenshot_hash self.last_views = views # 更新缓存 self.views_cache[current_screenshot_hash] = views # 限制缓存大小 (LRU) if len(self.views_cache) > 50: self.views_cache.pop(next(iter(self.views_cache))) try: os.remove(local_image_path) except: pass return views except Exception as e: self.logger.error(f"Failed to get views using CV mode: {e}") return None def send_intent(self, intent) -> str: """发送 Intent""" assert self.adb is not None assert intent is not None if hasattr(intent, 'get_cmd'): cmd = intent.get_cmd() else: cmd = intent return self.adb.shell(cmd) def push_file(self, local_file: str, remote_dir: str = "/sdcard/") -> None: """推送文件到设备""" if not os.path.exists(local_file): self.logger.warning("push_file file does not exist: %s" % local_file) self.adb.run_cmd(["push", local_file, remote_dir]) def pull_file(self, remote_file: str, local_file: str) -> None: """从设备拉取文件""" self.adb.run_cmd(["pull", remote_file, local_file]) def get_random_port(self) -> int: """获取随机端口""" import socket temp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) temp_sock.bind(("", 0)) port = temp_sock.getsockname()[1] temp_sock.close() if port in self._used_ports: return self.get_random_port() self._used_ports.append(port) return port def get_app_pid(self, app) -> Optional[int]: """获取应用 PID""" if hasattr(app, 'get_package_name'): package = app.get_package_name() else: package = app name2pid = {} ps_out = self.adb.shell(["ps"]) ps_out_lines = ps_out.splitlines() if len(ps_out_lines) > 0: ps_out_head = ps_out_lines[0].split() if len(ps_out_head) >= 2 and ps_out_head[1] != "PID": self.logger.warning("ps command output format error: %s" % ps_out_head) for ps_out_line in ps_out_lines[1:]: segs = ps_out_line.split() if len(segs) < 4: continue try: pid = int(segs[1]) name = segs[-1] name2pid[name] = pid except: continue if package in name2pid: return name2pid[package] possible_pids = [name2pid[name] for name in name2pid if name.startswith(package)] if len(possible_pids) > 0: return min(possible_pids) return None # ==================== 性能分析实现 ==================== def run_initial_setup(self) -> bool: from DroidBot.guiagent_bridge import GuiAgentBridge bridge = GuiAgentBridge(device=self, app=self.package_name) current_state = self.get_current_state() return bridge.handle_with_guiagent("game_initial", {"state": current_state}) def start_profiling(self, trace_file: str, sampling: Optional[int] = None) -> bool: """ Android特定的profiling实现 :param trace_file: 跟踪文件路径 :param sampling: 采样间隔(可选) :return: 是否成功启动 """ if not self._app: return False pid = self.get_app_pid(self._app) if pid is None: self.logger.warning("Cannot start profiling: app PID not found") return False try: if sampling is not None: self.adb.shell(["am", "profile", "start", "--sampling", str(sampling), str(pid), trace_file]) else: self.adb.shell(["am", "profile", "start", str(pid), trace_file]) self.logger.info(f"Profiling started for PID {pid}") return True except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.error(f"Failed to start profiling: {e}") return False def stop_profiling(self, trace_file: str, output_path: str) -> bool: """ Android特定的profiling停止实现 :param trace_file: 跟踪文件路径 :param output_path: 输出路径 :return: 是否成功停止 """ if not self._app: return False pid = self.get_app_pid(self._app) if pid is None: self.logger.warning("Cannot stop profiling: app PID not found") return False try: self.adb.shell(["am", "profile", "stop", str(pid)]) # Wait for trace file to be written import time time.sleep(2) # Pull trace file if output_path and os.path.exists(os.path.dirname(output_path)): self.pull_file(trace_file, output_path) self.logger.info(f"Profiling trace saved to {output_path}") return True except FATAL_EXCEPTIONS: raise except Exception as e: self.logger.error(f"Failed to stop profiling: {e}") return False