autool/DroidBot/platforms/android/android_device.py
2026-06-17 19:44:18 +08:00

1074 lines
43 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.

"""
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
# 解析 PIDps -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