1074 lines
43 KiB
Python
1074 lines
43 KiB
Python
"""
|
||
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
|
||
|