1705 lines
72 KiB
Python
1705 lines
72 KiB
Python
"""
|
||
iOS Device Implementation
|
||
Concrete implementation of AbstractDevice for iOS devices.
|
||
"""
|
||
import logging
|
||
import os
|
||
import time
|
||
from typing import Optional, Dict, Any, List
|
||
|
||
from ...core.abstract_device import AbstractDevice
|
||
from ...exceptions import FATAL_EXCEPTIONS
|
||
|
||
class IOSApp:
|
||
"""
|
||
iOS 应用的简单代理类
|
||
|
||
提供与 AndroidApp 类似的接口以兼容 EventLog 等组件。
|
||
"""
|
||
|
||
def __init__(self, bundle_id: str):
|
||
"""
|
||
初始化 iOS 应用
|
||
|
||
:param bundle_id: 应用的 Bundle ID
|
||
"""
|
||
self.bundle_id = bundle_id
|
||
self.package_name = bundle_id # 兼容 Android 接口
|
||
self.main_activity = bundle_id # iOS 没有 Activity,用 bundle_id 代替
|
||
self.activities = [bundle_id] # iOS 没有 Activity 列表
|
||
|
||
def get_package_name(self) -> str:
|
||
"""获取包名(bundle_id)"""
|
||
return self.bundle_id
|
||
|
||
def get_start_intent(self):
|
||
"""iOS 不使用 Intent,返回 None"""
|
||
return None
|
||
|
||
def get_stop_intent(self):
|
||
"""iOS 不使用 Intent,返回 None"""
|
||
return None
|
||
|
||
|
||
class IOSLogcat:
|
||
"""
|
||
iOS 日志代理类
|
||
|
||
iOS 没有 logcat,此类提供空实现以兼容 input_policy。
|
||
"""
|
||
|
||
def get_recent_lines(self) -> list:
|
||
"""获取最近的日志行(iOS 返回空列表)"""
|
||
return []
|
||
|
||
def clear(self):
|
||
"""清除日志"""
|
||
pass
|
||
|
||
|
||
class IOSDevice(AbstractDevice):
|
||
"""
|
||
iOS 设备的具体实现
|
||
|
||
继承自 AbstractDevice,通过 WebDriverAgent (WDA) 实现所有 iOS 特定的设备操作。
|
||
"""
|
||
|
||
def __init__(self, wda_url: str = "http://localhost:8100",
|
||
bundle_id: str = None, output_dir: str = None,
|
||
cv_mode: bool = False, udid: str = None,
|
||
debug_mode: bool = False):
|
||
"""
|
||
初始化 iOS 设备连接
|
||
|
||
:param wda_url: WDA 服务地址 (默认: http://localhost:8100)
|
||
:param bundle_id: 被测应用的 Bundle ID
|
||
:param output_dir: 输出目录
|
||
:param cv_mode: 是否启用 CV 模式
|
||
:param udid: 设备 UDID (可选,用于多设备场景)
|
||
:param debug_mode: 是否启用调试模式,输出详细日志
|
||
"""
|
||
super().__init__(output_dir=output_dir)
|
||
|
||
self.wda_url = wda_url
|
||
self.bundle_id = bundle_id
|
||
self.cv_mode = cv_mode
|
||
self.udid = udid
|
||
self.debug_mode = debug_mode # 调试模式标志
|
||
|
||
# 【修复】如果启用调试模式,设置 logger 级别为 DEBUG
|
||
if debug_mode:
|
||
import logging
|
||
self.logger.setLevel(logging.DEBUG)
|
||
|
||
# 内部管理的 App 对象(兼容 input_manager)
|
||
self._app = IOSApp(bundle_id) if bundle_id else None
|
||
|
||
# WDA 客户端(在 connect() 中初始化,GuiAgent 等早期调用通过 get_display_info 懒加载)
|
||
self._wda_client = None
|
||
self._session = None
|
||
|
||
# 设备信息缓存
|
||
self._device_info = None
|
||
self._window_size = None
|
||
|
||
# CV 模式相关缓存
|
||
self.last_screenshot_hash = None
|
||
self.last_views = None
|
||
self.views_cache = {} # 界面快照缓存: {dhash: views}
|
||
|
||
# 状态缓存:避免 source() 的重复调用
|
||
# 每步事件循环中 get_current_state() 被调用 3 次(generate_event / EventLog.start / EventLog.stop)
|
||
# 通过缓存实现一次性消费,每步只调用 1 次 source()
|
||
self._cached_state = None
|
||
|
||
# WDA 健康监控与卡死检测
|
||
self._wda_health_monitor = None # WDAHealthMonitor 实例(由外部注入)
|
||
self._consecutive_failures = 0 # 连续 WDA 操作失败计数
|
||
self.WDA_FAILURE_THRESHOLD = 3 # 触发恢复的失败阈值
|
||
|
||
# 事件控制标志(兼容 input_manager)
|
||
self.pause_sending_event = False
|
||
|
||
# UTG 兼容属性
|
||
self.serial = udid or "ios_device" # 设备序列号
|
||
self.minicap = None # iOS 不使用 minicap
|
||
self.adapters = {None: False} # 适配器字典,None 对应 minicap
|
||
|
||
# input_policy 兼容属性
|
||
self.logcat = IOSLogcat() # 日志代理
|
||
|
||
# 流量监控:存储 pcap 抓包输出目录(由外部在启动测试后注入)
|
||
# 父类将 captured_traffic_dir 定义为只读 property,用私有字段存储实际路径
|
||
self._captured_traffic_dir: Optional[str] = None
|
||
|
||
# pcap 抓包进程(由外部 ios_test.py 注入,供 is_traffic_capture_running / restart_traffic_capture 使用)
|
||
self._pcap_process = None
|
||
# 重启 pcap 时需要的参数(由外部注入)
|
||
self._pcap_restart_kwargs: dict = {}
|
||
|
||
self._last_state = None
|
||
|
||
|
||
# ==================== 平台信息 ====================
|
||
|
||
@property
|
||
def captured_traffic_dir(self) -> Optional[str]:
|
||
"""pcap 抓包输出目录(覆盖父类只读 property,提供 setter)"""
|
||
return self._captured_traffic_dir
|
||
|
||
@captured_traffic_dir.setter
|
||
def captured_traffic_dir(self, value: Optional[str]):
|
||
self._captured_traffic_dir = value
|
||
|
||
# ==================== pcap 进程管理 ====================
|
||
|
||
def set_pcap_process(self, process, restart_kwargs: dict = None):
|
||
"""注入 pcap 进程引用,供 is_traffic_capture_running / restart_traffic_capture 使用
|
||
|
||
:param process: subprocess.Popen 对象,由 ios_test.py start_pcap() 返回
|
||
:param restart_kwargs: 传给 start_pcap() 的关键字参数字典(含 bundle_id/udid/output_dir 等)
|
||
"""
|
||
self._pcap_process = process
|
||
self._pcap_restart_kwargs = restart_kwargs or {}
|
||
self.logger.info(f"pcap 进程已注入 (PID={process.pid if process else None})")
|
||
|
||
def is_traffic_capture_running(self) -> bool:
|
||
"""检查 pcap 抓包进程是否仍在运行
|
||
|
||
iOS 通过检查注入的 _pcap_process 的存活状态来判断。
|
||
若未注入进程引用,保守地返回 True(避免 check_monitor_health 误触发重启)。
|
||
"""
|
||
if self._pcap_process is None:
|
||
self.logger.debug("is_traffic_capture_running: 未注入 pcap 进程,跳过检查,默认返回 True")
|
||
return True
|
||
is_running = self._pcap_process.poll() is None
|
||
self.logger.debug(f"is_traffic_capture_running: PID={self._pcap_process.pid}, running={is_running}")
|
||
return is_running
|
||
|
||
def restart_traffic_capture(self, package_name: str = None):
|
||
"""重启 pcap 抓包进程
|
||
|
||
流程:
|
||
1. 终止旧进程
|
||
2. 用保存的 restart_kwargs 调用 ios_start.start_pcap() 重新启动
|
||
3. 更新 _pcap_process 引用
|
||
4. 重置增量读取状态(让 get_traffic_domains 从新文件头部重新读取)
|
||
"""
|
||
self.logger.info("[restart_traffic_capture] 开始重启 pcap 抓包...")
|
||
|
||
# 1. 终止旧进程
|
||
if self._pcap_process and self._pcap_process.poll() is None:
|
||
try:
|
||
self._pcap_process.terminate()
|
||
self._pcap_process.wait(timeout=5)
|
||
self.logger.info("[restart_traffic_capture] 旧 pcap 进程已终止")
|
||
except Exception as e:
|
||
self.logger.warning(f"[restart_traffic_capture] 终止旧进程异常: {e}")
|
||
try:
|
||
self._pcap_process.kill()
|
||
except Exception:
|
||
pass
|
||
|
||
# 若未注入重启参数,则无法重启
|
||
if not self._pcap_restart_kwargs:
|
||
self.logger.warning("[restart_traffic_capture] 未注入 pcap 重启参数,跳过重启")
|
||
return
|
||
|
||
# 2. 重启 pcap
|
||
try:
|
||
from ios_start import start_pcap
|
||
new_process = start_pcap(**self._pcap_restart_kwargs)
|
||
if new_process:
|
||
self._pcap_process = new_process
|
||
self.logger.info(
|
||
f"[restart_traffic_capture] 新 pcap 进程已启动 (PID={new_process.pid})"
|
||
)
|
||
else:
|
||
self.logger.error("[restart_traffic_capture] start_pcap 返回 None,重启失败")
|
||
except Exception as e:
|
||
self.logger.error(f"[restart_traffic_capture] 重启 pcap 失败: {e}")
|
||
|
||
# 3. 重置增量读取状态(新抓包文件会覆盖旧文件或生成新路径)
|
||
self._traffic_csv_path = None
|
||
self._traffic_csv_offset = 0
|
||
self._traffic_csv_header = None
|
||
self.logger.info("[restart_traffic_capture] 增量读取状态已重置")
|
||
|
||
|
||
def get_platform_name(self) -> str:
|
||
return "ios"
|
||
|
||
# ==================== 连接管理 ====================
|
||
|
||
def set_up(self) -> None:
|
||
"""设置设备连接前的准备工作"""
|
||
self.logger.info(f"Setting up iOS device connection to {self.wda_url}")
|
||
# iOS 设备通过 WDA 连接,无需额外设置
|
||
pass
|
||
|
||
def connect(self) -> bool:
|
||
"""连接到设备"""
|
||
try:
|
||
from .wda import Client
|
||
|
||
self.logger.info(f"Connecting to WDA at {self.wda_url}")
|
||
# 根据传入的连接参数选择连接方式:
|
||
# - http: 格式 → 使用 HTTP Client 连接
|
||
# - UDID 或空字符串 → 使用 USBClient 通过 USB 连接
|
||
if self.wda_url and self.wda_url.startswith("http:"):
|
||
self._wda_client = Client(self.wda_url)
|
||
else:
|
||
from .wda import USBClient
|
||
udid = self.wda_url if self.wda_url else (self.udid or "")
|
||
self._wda_client = USBClient(udid=udid)
|
||
|
||
# 等待 WDA 就绪
|
||
if not self._wda_client.wait_ready(timeout=30, noprint=True):
|
||
self.logger.error("WDA is not ready")
|
||
return False
|
||
|
||
# 获取设备信息
|
||
self._device_info = self._wda_client.status()
|
||
self.logger.info(f"Connected to iOS device: {self._device_info.get('os', {}).get('version', 'unknown')}")
|
||
|
||
# 获取显示信息
|
||
self.get_display_info()
|
||
|
||
# 注意:不在此处启动应用
|
||
# input_policy 会先发送 KillAppEvent 杀死应用,然后再启动
|
||
# 如果在这里启动,会导致:启动 -> 杀死 -> 再启动,第一次启动是多余的
|
||
self._session = self._wda_client
|
||
|
||
self.connected = True
|
||
return True
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to connect to iOS device: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
def disconnect(self) -> None:
|
||
"""断开设备连接"""
|
||
self.logger.info("Disconnecting from iOS device")
|
||
self.connected = False
|
||
|
||
# 先终止被测应用,防止设备继续执行操作
|
||
if self._wda_client and self.bundle_id:
|
||
try:
|
||
self.logger.info(f"Terminating app: {self.bundle_id}")
|
||
self._wda_client.app_terminate(self.bundle_id)
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to terminate app: {e}")
|
||
|
||
# 关闭 session
|
||
if self._session and hasattr(self._session, 'close'):
|
||
try:
|
||
self._session.close()
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to close session: {e}")
|
||
|
||
self._session = None
|
||
self._wda_client = None
|
||
|
||
def tear_down(self) -> None:
|
||
"""清理资源"""
|
||
self.logger.info("Tearing down iOS device resources")
|
||
# 清理临时文件
|
||
if self.output_dir:
|
||
temp_dir = os.path.join(self.output_dir, "temp")
|
||
if os.path.exists(temp_dir):
|
||
import shutil
|
||
shutil.rmtree(temp_dir)
|
||
|
||
def check_connectivity(self) -> bool:
|
||
"""检查连接状态"""
|
||
try:
|
||
return self._wda_client.status() is not None
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to check WDA connectivity: {e}")
|
||
return False
|
||
|
||
# ==================== 状态获取 ====================
|
||
|
||
def check_network(self, host: str = "8.8.8.8") -> bool:
|
||
"""
|
||
检查 iOS 设备内部网络是否连通
|
||
|
||
:param host: 测试目标主机(iOS 不使用此参数)
|
||
:return: True(假设网络可用)
|
||
"""
|
||
# 设备网络来自Mac共享,测试主机网络即可
|
||
try:
|
||
r = subprocess.run("ping -c 1 -W 2" + host, shell=True)
|
||
if "1 received" in r:
|
||
return True
|
||
else:
|
||
return False
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to check network connectivity: {e}")
|
||
return False
|
||
|
||
def get_current_state(self) -> 'IOSDeviceState':
|
||
"""获取当前设备状态
|
||
|
||
支持状态缓存:如果 _cached_state 不为空,直接返回缓存并清除。
|
||
每步事件循环中会被调用 3 次(generate_event / EventLog.start / EventLog.stop),
|
||
通过缓存机制避免重复调用 source(),从每步 3 次降为 1 次。
|
||
"""
|
||
# 优先返回缓存的状态(一次性消费)
|
||
if self._cached_state is not None:
|
||
cached = self._cached_state
|
||
self._cached_state = None
|
||
self.logger.debug("Using cached state (skip source() call)")
|
||
return cached
|
||
|
||
self.logger.debug("Getting current device state...")
|
||
# 广告拦截:在获取真正的 state/views 之前,先屏蔽并退出应用下载广告
|
||
if self._wda_client:
|
||
try:
|
||
# 兼容普通模式和 CV 模式的统一拦截:通过“获取”文字识别广告,通过“完成”退出
|
||
if self._wda_client(label='获取').exists:
|
||
self.logger.info("检测到应用下载广告,尝试点击'完成'退出...")
|
||
if self._wda_client(label='完成').click_exists(timeout=2.0):
|
||
self.logger.info("已成功确认广告页面并点击完成退出")
|
||
import time
|
||
time.sleep(1.5) # 等待动画和页面恢复
|
||
except Exception as e:
|
||
self.logger.debug(f"检查或处理应用下载广告异常: {e}")
|
||
|
||
current_state = None
|
||
try:
|
||
foreground_page = self._get_foreground_page()
|
||
|
||
is_target_app = (not self.bundle_id) or (foreground_page == self.bundle_id)
|
||
|
||
if not is_target_app:
|
||
self.logger.warning(f"get_current_state: 当前应用 {foreground_page} 非被测应用")
|
||
views = self._get_views()
|
||
screenshot_path = self.take_screenshot()
|
||
|
||
from .ios_device_state import IOSDeviceState
|
||
current_state = IOSDeviceState(
|
||
device=self,
|
||
views=views,
|
||
foreground_page=foreground_page,
|
||
screenshot_path=screenshot_path
|
||
)
|
||
except Exception as e:
|
||
self.logger.warning(f"Exception in get_current_state: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
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]:
|
||
"""获取显示信息
|
||
|
||
若 _wda_client 尚未初始化(如 GuiAgent 在 connect 之前调用),
|
||
先调用 connect() 建立连接。
|
||
"""
|
||
# 懒加载:如果 WDA 客户端还未建立,先连接
|
||
if self._wda_client is None:
|
||
self.logger.debug("get_display_info: _wda_client 为 None,尝试懒加载连接")
|
||
self.connect()
|
||
|
||
if self.display_info is None or refresh:
|
||
try:
|
||
window_size = self._wda_client.window_size()
|
||
scale = getattr(self._wda_client, 'scale', 3)
|
||
self.display_info = {
|
||
"width": int(window_size.width),
|
||
"height": int(window_size.height),
|
||
"scale": scale,
|
||
"density": scale * 160 # 近似 DPI
|
||
}
|
||
self._window_size = window_size
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to get display info: {e}")
|
||
self.display_info = {"width": 375, "height": 812, "scale": 2}
|
||
return self.display_info
|
||
|
||
# ==================== 屏幕操作 ====================
|
||
|
||
def take_screenshot(self, path: str = None) -> Optional[str]:
|
||
"""截取屏幕"""
|
||
if self.output_dir is None and path is None:
|
||
return None
|
||
|
||
try:
|
||
from datetime import datetime
|
||
tag = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||
|
||
if path is None:
|
||
local_image_dir = os.path.join(self.output_dir, "temp")
|
||
if not os.path.exists(local_image_dir):
|
||
os.makedirs(local_image_dir)
|
||
local_image_path = os.path.join(local_image_dir, f"screen_{tag}.png")
|
||
else:
|
||
local_image_path = path
|
||
|
||
# 使用 WDA 截图
|
||
self._wait_wda_ready()
|
||
self._wda_client.screenshot(local_image_path)
|
||
# TODO 将图片按照scale降采样到1/3
|
||
try:
|
||
from PIL import Image
|
||
with Image.open(local_image_path) as img:
|
||
width, height = img.size
|
||
scale = self.get_display_info().get("scale", 3)
|
||
if scale > 1:
|
||
# 降采样到逻辑分辨率
|
||
new_size = (int(width / scale), int(height / scale))
|
||
img = img.resize(new_size, Image.LANCZOS)
|
||
img.save(local_image_path)
|
||
except ImportError:
|
||
self.logger.warning("PIL not installed, skipping screenshot resize")
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to resize screenshot: {e}")
|
||
return local_image_path
|
||
except FATAL_EXCEPTIONS:
|
||
raise # WDAStuckError 等致命异常继续向上传播
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to take screenshot: {e}")
|
||
self._on_wda_failure(f"take_screenshot: {e}")
|
||
return None
|
||
|
||
def unlock(self) -> None:
|
||
"""解锁屏幕"""
|
||
try:
|
||
if self._wda_client.locked():
|
||
self._wda_client.unlock()
|
||
time.sleep(1)
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to unlock: {e}")
|
||
|
||
# ==================== 事件发送 ====================
|
||
|
||
def set_health_monitor(self, monitor):
|
||
"""设置 WDA 健康监控实例
|
||
|
||
:param monitor: WDAHealthMonitor 实例
|
||
"""
|
||
self._wda_health_monitor = monitor
|
||
self.logger.info("WDA 健康监控已设置")
|
||
|
||
def _wait_wda_ready(self, timeout: float = 30) -> bool:
|
||
"""
|
||
等待 WDA 就绪
|
||
|
||
在执行任何操作前调用此方法,确保 WDA 已就绪(之前的操作/动画已完成)。
|
||
如果 WDA 正在恢复中,自动延长超时时间。
|
||
|
||
:param timeout: 超时时间(秒)
|
||
:return: WDA 是否就绪
|
||
"""
|
||
if not self._wda_client:
|
||
return False
|
||
try:
|
||
# 如果健康监控正在恢复中,延长等待超时
|
||
actual_timeout = timeout
|
||
if self._wda_health_monitor and self._wda_health_monitor.is_recovering:
|
||
actual_timeout = max(timeout, 60) # 恢复中至少等 60s
|
||
self.logger.info(f"WDA 正在恢复中,延长等待超时 (timeout={actual_timeout}s)...")
|
||
|
||
ready = self._wda_client.wait_ready(timeout=actual_timeout, noprint=True)
|
||
if ready:
|
||
self._on_wda_success()
|
||
else:
|
||
self._on_wda_failure("wait_ready 超时")
|
||
return ready
|
||
except Exception as e:
|
||
self.logger.warning(f"Exception while waiting for WDA ready: {e}")
|
||
self._on_wda_failure(str(e))
|
||
return False
|
||
|
||
def _on_wda_failure(self, reason: str = ""):
|
||
"""WDA 操作失败时调用:累加计数,达到阈值触发异步恢复"""
|
||
self._consecutive_failures += 1
|
||
self.logger.warning(
|
||
f"WDA 操作失败 ({reason}), 连续失败: "
|
||
f"{self._consecutive_failures}/{self.WDA_FAILURE_THRESHOLD}"
|
||
)
|
||
|
||
if self._wda_health_monitor:
|
||
# 检查是否已超过最大恢复次数
|
||
if self._wda_health_monitor.has_exceeded_max_attempts():
|
||
from .wda.exceptions import WDAStuckError
|
||
raise WDAStuckError(
|
||
f"WDA 卡死:连续 {self._wda_health_monitor.MAX_RECOVER_ATTEMPTS} "
|
||
f"次恢复均失败,终止当前测试"
|
||
)
|
||
|
||
# 达到失败阈值,触发异步恢复
|
||
if self._consecutive_failures >= self.WDA_FAILURE_THRESHOLD:
|
||
self.logger.warning("达到失败阈值,触发 WDA 异步恢复...")
|
||
self._wda_health_monitor.trigger_recovery()
|
||
self._consecutive_failures = 0 # 重置计数,等待恢复结果
|
||
|
||
def _on_wda_success(self):
|
||
"""WDA 操作成功时调用:重置失败计数和恢复计数"""
|
||
if self._consecutive_failures > 0:
|
||
self.logger.info(
|
||
f"WDA 恢复正常 (之前连续失败 {self._consecutive_failures} 次)"
|
||
)
|
||
self._consecutive_failures = 0
|
||
if self._wda_health_monitor:
|
||
self._wda_health_monitor.reset_recover_count()
|
||
|
||
def send_event(self, event) -> bool:
|
||
"""发送输入事件"""
|
||
event_str = event.get_event_str() if hasattr(event, 'get_event_str') else str(event)
|
||
self.logger.debug(f"send_event: 准备发送事件 {event_str}")
|
||
try:
|
||
# 等待 WDA 就绪后再发送事件
|
||
self.logger.debug(f"send_event: 等待 WDA 就绪...")
|
||
if not self._wait_wda_ready(timeout=10):
|
||
self.logger.warning("WDA not ready, skipping event")
|
||
return False
|
||
self.logger.debug(f"send_event: WDA 就绪,发送事件...")
|
||
event.send(self)
|
||
self.logger.debug(f"send_event: 事件发送完成")
|
||
return True
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to send event: {e}")
|
||
return False
|
||
|
||
def view_touch(self, x: int, y: int) -> None:
|
||
"""触摸指定坐标"""
|
||
self.logger.debug(f"view_touch: 等待 WDA 就绪...")
|
||
self._wait_wda_ready()
|
||
self.logger.debug(f"view_touch: 执行点击 ({x}, {y})")
|
||
self._wda_client.tap(x, y)
|
||
self.logger.debug(f"view_touch: 点击完成")
|
||
|
||
def view_long_touch(self, x: int, y: int, duration: float = 2.0) -> None:
|
||
"""长按指定坐标"""
|
||
self.logger.debug(f"view_long_touch: 等待 WDA 就绪...")
|
||
self._wait_wda_ready()
|
||
self.logger.debug(f"view_long_touch: 执行长按 ({x}, {y}), 时长={duration}s")
|
||
# WDA 使用秒为单位
|
||
self._wda_client.tap_hold(x, y, duration)
|
||
self.logger.debug(f"view_long_touch: 长按完成")
|
||
|
||
def view_drag(self, start_xy: tuple, end_xy: tuple, duration: float = 0.5) -> None:
|
||
"""拖拽操作"""
|
||
self.logger.debug(f"view_drag: 等待 WDA 就绪...")
|
||
self._wait_wda_ready()
|
||
# 针对安卓接口单位为ms问题放缩duration
|
||
if duration > 100:
|
||
duration = duration / 1000
|
||
self.logger.debug(f"view_drag: 执行拖拽 {start_xy} -> {end_xy}, 时长={duration}s")
|
||
self._wda_client.swipe(start_xy[0], start_xy[1], end_xy[0], end_xy[1], duration)
|
||
self.logger.debug(f"view_drag: 拖拽完成")
|
||
|
||
def view_set_text(self, text: str) -> None:
|
||
"""设置文本"""
|
||
self.logger.debug(f"view_set_text: 等待 WDA 就绪...")
|
||
self._wait_wda_ready()
|
||
text_preview = text[:20] + '...' if len(text) > 20 else text
|
||
self.logger.debug(f"view_set_text: 输入文本 '{text_preview}'")
|
||
# 使用 WDA 输入文本
|
||
try:
|
||
self._wda_client.send_keys(text)
|
||
self.logger.debug(f"view_set_text: 文本输入完成")
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to set text: {e}")
|
||
|
||
def _swipe_back(self):
|
||
"""实现iOS返回:从屏幕左边缘滑向中间"""
|
||
display_info = self.get_display_info()
|
||
width = display_info.get("width", 375)
|
||
height = display_info.get("height", 812)
|
||
|
||
start_x = 0
|
||
start_y = int(height / 2)
|
||
end_x = width - int(width / 6)
|
||
end_y = int(height / 2)
|
||
|
||
self.logger.debug(f"_swipe_back: ({start_x}, {start_y}) -> ({end_x}, {end_y})")
|
||
self._wda_client.swipe(start_x, start_y, end_x, end_y, 0.2)
|
||
|
||
def key_press(self, key_code: str) -> None:
|
||
"""按键操作"""
|
||
self.logger.debug(f"key_press: 等待 WDA 就绪...")
|
||
self._wait_wda_ready()
|
||
self.logger.debug(f"key_press: 执行按键 '{key_code}'")
|
||
key_map = {
|
||
"HOME": lambda: self._wda_client.home(),
|
||
"BACK": self._swipe_back, # 使用滑动侧边实现返回
|
||
"VOLUME_UP": lambda: self._wda_client.press_volume_up(),
|
||
"VOLUME_DOWN": lambda: self._wda_client.press_volume_down(),
|
||
}
|
||
|
||
if key_code.upper() in key_map:
|
||
key_map[key_code.upper()]()
|
||
self.logger.debug(f"key_press: 按键完成")
|
||
else:
|
||
self.logger.warning(f"Unsupported key code: {key_code}")
|
||
|
||
# ==================== 应用管理 ====================
|
||
|
||
@property
|
||
def app_identifier(self) -> str:
|
||
"""获取被测应用的唯一标识符(bundle_id)"""
|
||
return self.bundle_id or ""
|
||
|
||
def is_foreground(self) -> bool:
|
||
"""检查被测应用是否在前台"""
|
||
if not self.bundle_id:
|
||
return False
|
||
try:
|
||
# 等待 WDA 就绪(恢复期间会自动延长超时)
|
||
if not self._wait_wda_ready(timeout=10):
|
||
self.logger.warning("is_foreground: WDA 未就绪")
|
||
return False
|
||
current_app = self._wda_client.app_current()
|
||
is_fg = current_app.get("bundleId") == self.bundle_id
|
||
|
||
# 当 springboard 在前台时,使用 WDA 的 app_state 检查被测应用的真实状态
|
||
if current_app.get("bundleId") == "com.apple.springboard":
|
||
try:
|
||
# 使用 WDA 原生方法检查应用状态
|
||
# XCUIApplicationState 枚举值:
|
||
# 0 = unknown (未知)
|
||
# 1 = notRunning (未运行)
|
||
# 2 = runningBackgroundSuspended (后台挂起)
|
||
# 3 = runningBackground (后台运行)
|
||
# 4 = runningForeground (前台运行)
|
||
state_result = self._wda_client.app_state(self.bundle_id)
|
||
app_state = state_result.get("value", 1)
|
||
|
||
if app_state == 4:
|
||
# 应用在前台运行(通常是被 springboard 遮挡)
|
||
is_fg = True
|
||
self.logger.debug(f"is_foreground: Springboard 显示,但被测应用状态=前台运行(4)")
|
||
elif app_state in [2, 3]:
|
||
# 应用在后台运行或挂起
|
||
is_fg = False
|
||
state_name = "后台挂起" if app_state == 2 else "后台运行"
|
||
self.logger.debug(f"is_foreground: 应用在{state_name},状态值={app_state}")
|
||
else:
|
||
# 应用未运行或状态未知 (0 或 1)
|
||
is_fg = False
|
||
state_name = "未知" if app_state == 0 else "未运行"
|
||
self.logger.debug(f"is_foreground: 应用{state_name},状态值={app_state}")
|
||
except Exception as e:
|
||
# 如果 app_state 调用失败,保守地认为应用不在前台
|
||
self.logger.warning(f"Failed to check app state: {e}, assuming not foreground")
|
||
is_fg = False
|
||
|
||
self.logger.debug(f"is_foreground: 当前应用={current_app.get('bundleId')}, 目标={self.bundle_id}, 结果={is_fg}")
|
||
return is_fg
|
||
except FATAL_EXCEPTIONS:
|
||
raise # WDAStuckError 等致命异常继续向上传播
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to check foreground: {e}")
|
||
self._on_wda_failure(f"is_foreground: {e}")
|
||
return False
|
||
|
||
def pull_back_to_app(self) -> bool:
|
||
"""将被测应用拉回前台"""
|
||
if not self.bundle_id:
|
||
return True
|
||
|
||
if self.is_foreground():
|
||
return True
|
||
|
||
self.logger.debug(f"pull_back_to_app: 拉回应用 {self.bundle_id}")
|
||
self.logger.info(f"Pulling back to app: {self.bundle_id}")
|
||
try:
|
||
self._wda_client.app_activate(self.bundle_id)
|
||
time.sleep(1)
|
||
result = self.is_foreground()
|
||
self.logger.debug(f"pull_back_to_app: 结果={result}")
|
||
return result
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to pull back to app: {e}")
|
||
return False
|
||
|
||
def _is_springboard_with_app_foreground(self) -> bool:
|
||
"""
|
||
检查是否是 springboard 遮挡场景
|
||
|
||
当 springboard 在前台,但被测应用状态为前台运行(state=4)时,
|
||
表示出现了系统弹窗(如权限请求对话框)遮挡应用界面。
|
||
|
||
Returns:
|
||
bool: True 表示 springboard 遮挡场景,False 表示正常场景
|
||
"""
|
||
if not self.bundle_id:
|
||
return False
|
||
|
||
try:
|
||
current_app = self._wda_client.app_current()
|
||
if current_app.get("bundleId") == "com.apple.springboard":
|
||
# Springboard 在前台,检查被测应用状态
|
||
state_result = self._wda_client.app_state(self.bundle_id)
|
||
app_state = state_result.get("value", 1)
|
||
|
||
if app_state == 4:
|
||
# 被测应用在前台运行但被 springboard 遮挡(系统弹窗)
|
||
self.logger.debug(f"检测到 springboard 遮挡场景(系统弹窗)")
|
||
return True
|
||
except Exception as e:
|
||
self.logger.debug(f"springboard 检测失败: {e}")
|
||
|
||
return False
|
||
|
||
def start_app(self) -> bool:
|
||
"""启动被测应用"""
|
||
if not self.bundle_id:
|
||
self.logger.warning("No bundle_id specified, cannot start app")
|
||
return False
|
||
|
||
self.logger.debug(f"start_app: 启动应用 {self.bundle_id}")
|
||
try:
|
||
# 等待 WDA 就绪(恢复期间会自动延长超时)
|
||
if not self._wait_wda_ready(timeout=10):
|
||
self.logger.warning("start_app: WDA 未就绪,跳过启动")
|
||
return False
|
||
self._wda_client.app_launch(self.bundle_id)
|
||
time.sleep(4)
|
||
self.logger.debug(f"start_app: 应用启动完成")
|
||
|
||
# 检测是否为游戏应用,如果是则自动启用 CV 模式
|
||
self._check_and_enable_cv_mode_if_game()
|
||
|
||
return True
|
||
except FATAL_EXCEPTIONS:
|
||
raise # WDAStuckError 等致命异常继续向上传播
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to start app: {e}")
|
||
self._on_wda_failure(f"start_app: {e}")
|
||
return False
|
||
|
||
def _check_and_enable_cv_mode_if_game(self) -> None:
|
||
"""
|
||
检测应用是否为游戏,如果是则自动启用 CV 模式。
|
||
|
||
iOS 游戏(Unity/Unreal 等)的 WDA source 树通常只含容器型节点
|
||
(Application / Window / Other),没有任何标准控件(Button / Cell /
|
||
TextField / StaticText 等),叶子节点 traits 会包含
|
||
'AllowsDirectInteraction'(OpenGL/Metal 渲染画布的特征)。
|
||
|
||
检测条件(满足任一即判定为游戏并启用 CV 模式):
|
||
1. source 树中完全没有标准控件类型节点
|
||
2. 存在 traits 含 'AllowsDirectInteraction' 的节点(游戏渲染画布)
|
||
"""
|
||
# 已经是 CV 模式则无需检测
|
||
if self.cv_mode:
|
||
return
|
||
|
||
try:
|
||
time.sleep(5)
|
||
|
||
# 先检测并处理系统弹窗(如"允许跟踪"等权限对话框)
|
||
# 系统弹窗包含 Button 等标准控件,会导致游戏误判为普通应用
|
||
MAX_ALERT_ROUNDS = 3 # 最多处理3轮弹窗(有些应用会连续弹出多个权限请求)
|
||
for round_idx in range(MAX_ALERT_ROUNDS):
|
||
if self._is_springboard_with_app_foreground():
|
||
try:
|
||
alert_text = None
|
||
try:
|
||
alert_text = self._wda_client.alert.text
|
||
except Exception:
|
||
pass
|
||
self.logger.info(
|
||
f"_check_game: 检测到系统弹窗(第{round_idx + 1}轮),"
|
||
f"内容: {alert_text},尝试接受弹窗"
|
||
)
|
||
self._wda_client.alert.accept()
|
||
time.sleep(2) # 等待弹窗关闭和界面切换
|
||
except Exception as e:
|
||
self.logger.debug(f"_check_game: 处理系统弹窗失败: {e}")
|
||
break
|
||
else:
|
||
if round_idx > 0:
|
||
self.logger.info(
|
||
f"_check_game: 系统弹窗已全部处理(共{round_idx}轮)"
|
||
)
|
||
break
|
||
|
||
# 获取当前 UI 树
|
||
source = self._wda_client.source(format='json')
|
||
if not source:
|
||
self.logger.debug("_check_game: source 为空,跳过检测")
|
||
return
|
||
|
||
# 标准控件类型(游戏应用通常没有这些节点)
|
||
STANDARD_CONTROL_TYPES = {
|
||
'Button', 'Cell', 'TextField', 'SecureTextField',
|
||
'StaticText', 'Switch', 'Link', 'SearchField',
|
||
'Slider', 'SegmentedControl', 'Picker',
|
||
}
|
||
|
||
found_standard_control = False
|
||
found_direct_interaction = False
|
||
|
||
def _traverse(node: dict) -> None:
|
||
"""递归遍历 source 树,统计节点特征"""
|
||
nonlocal found_standard_control, found_direct_interaction
|
||
if not isinstance(node, dict):
|
||
return
|
||
|
||
node_type = node.get('type', '')
|
||
node_traits = node.get('traits', '') or ''
|
||
|
||
# 检查是否包含标准控件类型
|
||
if node_type in STANDARD_CONTROL_TYPES:
|
||
found_standard_control = True
|
||
|
||
# 检查是否存在 AllowsDirectInteraction(游戏渲染画布特征)
|
||
if 'AllowsDirectInteraction' in node_traits:
|
||
found_direct_interaction = True
|
||
|
||
# 递归处理子节点
|
||
for child in node.get('children', []) or []:
|
||
_traverse(child)
|
||
|
||
_traverse(source)
|
||
|
||
# 判定逻辑:有 AllowsDirectInteraction 或完全没有标准控件,判定为游戏
|
||
if found_direct_interaction:
|
||
self.logger.info(
|
||
f"检测到游戏应用(source 含 AllowsDirectInteraction 渲染画布),"
|
||
f"自动启用 CV 模式 [bundle_id={self.bundle_id}]"
|
||
)
|
||
self.cv_mode = True
|
||
elif not found_standard_control:
|
||
self.logger.info(
|
||
f"检测到游戏应用(source 中无任何标准控件节点),"
|
||
f"自动启用 CV 模式 [bundle_id={self.bundle_id}]"
|
||
)
|
||
self.cv_mode = True
|
||
else:
|
||
self.logger.info(
|
||
f"应用包含标准控件节点,判定为普通应用,使用控件模式 "
|
||
f"[bundle_id={self.bundle_id}]"
|
||
)
|
||
except FATAL_EXCEPTIONS:
|
||
raise
|
||
except Exception as e:
|
||
self.logger.debug(f"_check_game: 游戏检测失败(忽略): {e}")
|
||
|
||
def install_app(self) -> bool:
|
||
"""安装应用(iOS 通过其他方式安装,这里跳过)"""
|
||
self.logger.info("Skipping install_app: iOS apps should be pre-installed")
|
||
return True
|
||
|
||
def uninstall_app(self) -> bool:
|
||
"""卸载应用(保留应用环境)"""
|
||
self.logger.info("Skipping uninstall_app: Preserving app environment")
|
||
return True
|
||
|
||
# ==================== iOS 特定方法 ====================
|
||
|
||
def _get_views(self) -> Optional[List[Dict[str, Any]]]:
|
||
"""获取当前界面视图"""
|
||
self.logger.debug(f"_get_views: 等待 WDA 就绪...")
|
||
self._wait_wda_ready()
|
||
if self.cv_mode:
|
||
self.logger.debug(f"_get_views: 使用 CV 模式获取视图")
|
||
return self._get_views_cv_mode()
|
||
|
||
try:
|
||
# 检查是否是 springboard 活跃但被测应用在前台的情况(系统弹窗遮挡)
|
||
# 在这种情况下,source() API 可能无法获取应用的 UI 层级
|
||
# 需要使用 find_elements() 的方式获取控件
|
||
if self._is_springboard_with_app_foreground():
|
||
# 被测应用在前台运行但被 springboard 遮挡(系统弹窗)
|
||
# 使用 find_elements() 方式获取控件
|
||
self.logger.debug(f"_get_views: 检测到 springboard 遮挡场景,使用 find_elements() 获取控件")
|
||
return self._get_views_from_elements()
|
||
|
||
# 默认方式:使用 WDA 获取界面层次结构
|
||
self.logger.debug(f"_get_views: 使用 WDA source() 获取界面层次结构")
|
||
source = self._wda_client.source(format='json')
|
||
views = self._parse_wda_source(source)
|
||
self.logger.debug(f"_get_views: 获取到 {len(views) if views else 0} 个视图")
|
||
return views
|
||
except FATAL_EXCEPTIONS:
|
||
raise # WDAStuckError 等致命异常继续向上传播
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to get views: {e}")
|
||
return None
|
||
|
||
def _parse_wda_source(self, source: Dict) -> List[Dict[str, Any]]:
|
||
"""
|
||
解析 WDA 返回的界面层次结构,转换为统一的 ViewDict 格式
|
||
|
||
iOS WDA 返回的结构字段:
|
||
- name: 元素标识符/名称
|
||
- type: 元素类型 (Button, Cell, CollectionView, etc.)
|
||
- label: 显示的文本
|
||
- value: 值
|
||
- children: 子元素列表
|
||
- rawIdentifier: 原始标识符
|
||
- rect/frame: 边界信息(可能不存在)
|
||
"""
|
||
views = []
|
||
temp_id_counter = [0] # 使用列表以便在递归中修改
|
||
|
||
# 【优化】在解析前获取一次前台页面,避免为每个视图都调用 WDA
|
||
foreground_page = self._get_foreground_page() or ""
|
||
|
||
# 获取屏幕尺寸用于默认边界
|
||
display_info = self.get_display_info()
|
||
screen_width = display_info.get("width", 375)
|
||
screen_height = display_info.get("height", 812)
|
||
|
||
def parse_element(element: Dict, parent_id: int = -1) -> int:
|
||
"""递归解析元素"""
|
||
if element is None:
|
||
return -1
|
||
|
||
current_id = temp_id_counter[0]
|
||
temp_id_counter[0] += 1
|
||
# 获取边界(可能不存在)
|
||
frame = element.get("rect", element.get("frame", {}))
|
||
if frame:
|
||
x = frame.get("x", 0) or 0
|
||
y = frame.get("y", 0) or 0
|
||
width = frame.get("width", 0) or 0
|
||
height = frame.get("height", 0) or 0
|
||
else:
|
||
# 没有边界信息,使用默认值
|
||
x, y, width, height = 0, 0, 0, 0
|
||
bounds = [[int(x), int(y)], [int(x + width), int(y + height)]]
|
||
|
||
# 获取文本内容(优先使用 label,其次 value,最后 name)
|
||
text = element.get("label", "") or ""
|
||
value = element.get("value", "") or ""
|
||
name = element.get("name", "") or ""
|
||
# text = label or value or ""
|
||
|
||
# 获取元素类型
|
||
element_type = element.get("type", "Unknown") or "Unknown"
|
||
|
||
# 判断元素属性
|
||
is_button = element_type in ["Button","XCUIElementTypeButton"]
|
||
is_scrollable = element_type in [ "Table", "TableView", "XCUIElementTypeTable", "XCUIElementTypeTableView"] # "ScrollView", "CollectionView",
|
||
is_editable = element_type in [
|
||
# 标准类型名
|
||
"TextField", "SecureTextField", "TextEditor", "SearchField",
|
||
# XCUIElement 类型名
|
||
"XCUIElementTypeTextField",
|
||
"XCUIElementTypeSecureTextField", "XCUIElementTypeSearchField",
|
||
"XCUIElementTypeTextEditor"
|
||
]
|
||
|
||
# 构建 ViewDict
|
||
view = {
|
||
"temp_id": current_id,
|
||
"parent": parent_id,
|
||
"bounds": bounds,
|
||
"text": text,
|
||
# "content_description": label,
|
||
"resource_id": element.get("rawIdentifier", "") or name,
|
||
"class_name": element_type,
|
||
"visible": bool(element.get("isVisible", True)) if element.get("isVisible") is not None else False,
|
||
"enabled": bool(element.get("isEnabled", True)) if element.get("isEnabled") is not None else False,
|
||
"clickable": is_button, # or element.get("accessible", False),
|
||
"scrollable": is_scrollable,
|
||
"editable": is_editable,
|
||
"children": [],
|
||
"source": "accessibility",
|
||
# iOS 特有字段
|
||
"ios_name": name,
|
||
"ios_value": value,
|
||
}
|
||
|
||
# 处理子元素
|
||
children = element.get("children", [])
|
||
child_ids = []
|
||
if children:
|
||
for child in children:
|
||
if child is not None:
|
||
child_id = parse_element(child, current_id)
|
||
if child_id >= 0:
|
||
child_ids.append(child_id)
|
||
|
||
view["children"] = child_ids
|
||
|
||
views.append(view)
|
||
return current_id
|
||
|
||
# 从根元素开始解析
|
||
if source:
|
||
parse_element(source, -1)
|
||
|
||
# 按 temp_id 排序
|
||
views.sort(key=lambda v: v["temp_id"])
|
||
|
||
# 生成 view_str(所有视图解析完成后),传入已获取的 foreground_page
|
||
self._generate_view_strs(views, foreground_page)
|
||
|
||
return views
|
||
|
||
def _generate_view_strs(self, views: List[Dict[str, Any]], foreground_page: str) -> None:
|
||
"""生成所有视图的唯一标识字符串(仿照 Android 实现)"""
|
||
for view_dict in views:
|
||
self._get_view_str(view_dict, views, foreground_page)
|
||
|
||
def _get_view_str(self, view_dict: Dict[str, Any], views: List[Dict[str, Any]], foreground_page: str) -> str:
|
||
"""
|
||
获取视图字符串(仿照 Android 的 _get_view_str 实现)
|
||
|
||
生成规则:
|
||
- Page: 当前前台页面(bundle_id)
|
||
- Self: 视图自身签名
|
||
- Parents: 所有祖先视图签名(从根到父,用 // 连接)
|
||
- Children: 所有子视图签名(排序后用 || 连接)
|
||
- 最终用 MD5 哈希
|
||
"""
|
||
if 'view_str' in view_dict and view_dict['view_str']:
|
||
return view_dict['view_str']
|
||
|
||
view_signature = self._get_view_signature(view_dict)
|
||
|
||
# 获取所有祖先签名
|
||
parent_strs = []
|
||
for parent_id in self._get_all_ancestors(view_dict, views):
|
||
if 0 <= parent_id < len(views):
|
||
parent_strs.append(self._get_view_signature(views[parent_id]))
|
||
parent_strs.reverse() # 从根到父的顺序
|
||
|
||
# 获取所有子视图签名
|
||
child_strs = []
|
||
for child_id in self._get_all_children(view_dict, views):
|
||
if 0 <= child_id < len(views):
|
||
child_strs.append(self._get_view_signature(views[child_id]))
|
||
child_strs.sort()
|
||
|
||
# 【优化】使用传入的 foreground_page,而不是每次都调用 WDA
|
||
# 构建视图字符串(格式与 Android 一致)
|
||
view_str = "Page:%s\nSelf:%s\nParents:%s\nChildren:%s" % (
|
||
foreground_page, view_signature,
|
||
"//".join(parent_strs), "||".join(child_strs)
|
||
)
|
||
|
||
import hashlib
|
||
view_str = hashlib.md5(view_str.encode('utf-8')).hexdigest()
|
||
view_dict['view_str'] = view_str
|
||
return view_str
|
||
|
||
@staticmethod
|
||
def _get_view_signature(view_dict: Dict[str, Any]) -> str:
|
||
"""
|
||
获取视图签名(仿照 Android 的 _get_view_signature 实现)
|
||
|
||
签名格式: [class]类名[resource_id]资源ID[text]文本[enabled,checked,selected]
|
||
"""
|
||
if 'signature' in view_dict:
|
||
return view_dict['signature']
|
||
|
||
view_text = view_dict.get('text', "None") or "None"
|
||
if len(view_text) > 50:
|
||
view_text = "None" # 文本过长时忽略
|
||
|
||
# 构建签名
|
||
signature = "[class]%s[resource_id]%s[text]%s[%s,%s,%s]" % (
|
||
view_dict.get('class_name', "None") or "None",
|
||
view_dict.get('resource_id', "None") or "None",
|
||
view_text,
|
||
"enabled" if view_dict.get('enabled') else "",
|
||
"checked" if view_dict.get('checked') else "",
|
||
"selected" if view_dict.get('selected') else ""
|
||
)
|
||
view_dict['signature'] = signature
|
||
return signature
|
||
|
||
@staticmethod
|
||
def _get_all_ancestors(view_dict: Dict[str, Any], views: List[Dict[str, Any]]) -> List[int]:
|
||
"""获取所有祖先节点 ID"""
|
||
result = []
|
||
parent_id = view_dict.get('parent', -1)
|
||
while 0 <= parent_id < len(views):
|
||
result.append(parent_id)
|
||
parent_id = views[parent_id].get('parent', -1)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _get_all_children(view_dict: Dict[str, Any], views: List[Dict[str, Any]]) -> List[int]:
|
||
"""获取所有子节点 ID(递归)"""
|
||
children = view_dict.get('children', [])
|
||
if not children:
|
||
return []
|
||
|
||
result = list(children)
|
||
for child_id in children:
|
||
if 0 <= child_id < len(views):
|
||
result.extend(IOSDevice._get_all_children(views[child_id], views))
|
||
return result
|
||
|
||
def _get_views_cv_mode(self) -> Optional[List[Dict[str, Any]]]:
|
||
"""CV 模式获取视图 - 使用统一的 ViewDict 格式"""
|
||
try:
|
||
from DroidBot.cv import cv
|
||
|
||
# 使用已有的 take_screenshot() 获取截图路径
|
||
local_image_path = self.take_screenshot()
|
||
if not local_image_path:
|
||
self.logger.error("_get_views_cv_mode: 截图失败")
|
||
return None
|
||
|
||
img = cv.load_image_from_path(local_image_path)
|
||
current_screenshot_hash = cv.calculate_dhash(img)
|
||
|
||
# 模糊匹配缓存(相似度 > 88% 视为命中)
|
||
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)
|
||
if similarity > 0.88:
|
||
best_match_views = self.views_cache[cached_hash]
|
||
break
|
||
|
||
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
|
||
|
||
# 未命中缓存,调用 CV 识别控件
|
||
cv_views = cv.find_views(img)
|
||
|
||
# 构建根视图
|
||
display_info = self.get_display_info()
|
||
width = display_info.get('width', 390)
|
||
height = display_info.get('height', 844)
|
||
|
||
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",
|
||
"parent": -1,
|
||
}
|
||
|
||
# 重新分配 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
|
||
|
||
# 更新缓存(LRU,上限 50 个)
|
||
self.views_cache[current_screenshot_hash] = views
|
||
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 FATAL_EXCEPTIONS:
|
||
raise
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to get views using CV mode: {e}")
|
||
return None
|
||
|
||
def _get_views_from_elements(self, fetch_all: bool = False) -> Optional[List[Dict[str, Any]]]:
|
||
"""
|
||
使用 find_elements() 获取界面控件(用于 springboard 场景)
|
||
|
||
当 springboard 活跃但被测应用在前台(state=4)时使用此方法。
|
||
通过并行请求获取所有可交互元素的属性。
|
||
|
||
Args:
|
||
fetch_all: 是否获取所有元素的完整属性。默认 False,此时当元素数量 > 5 时,
|
||
只获取第一个和最后一个元素的完整属性,其他元素只获取 label 和 value属性
|
||
|
||
Returns:
|
||
包含所有元素的视图列表
|
||
"""
|
||
self.logger.debug(f"_get_views_from_elements: 使用 find_elements() 获取界面控件 (fetch_all={fetch_all})")
|
||
|
||
try:
|
||
import concurrent.futures
|
||
|
||
# 查找所有常见的可交互元素类型
|
||
element_types = ['Button','TextField','SearchField']
|
||
|
||
# 记录元素及其类型
|
||
all_elements = [] # [(element, element_type), ...]
|
||
for elem_type in element_types:
|
||
try:
|
||
elements = self._wda_client(type=elem_type).find_elements()
|
||
# 将元素和类型一起存储
|
||
for elem in elements:
|
||
all_elements.append((elem, elem_type))
|
||
self.logger.debug(f"找到 {len(elements)} 个 {elem_type} 元素")
|
||
except Exception as e:
|
||
self.logger.debug(f"查找 {elem_type} 失败: {e}")
|
||
continue
|
||
|
||
if not all_elements:
|
||
self.logger.warning("未找到任何界面元素")
|
||
return []
|
||
|
||
self.logger.debug(f"总共找到 {len(all_elements)} 个元素,开始并行获取属性...")
|
||
|
||
# 并行获取元素属性(根据 fetch_all 参数和元素类型决定是否获取全部属性)
|
||
element_properties = self._fetch_element_properties_parallel(all_elements, fetch_all=fetch_all)
|
||
|
||
self.logger.debug(f"成功获取 {len(element_properties)} 个元素的属性")
|
||
|
||
# 转换为标准视图格式
|
||
views = self._parse_elements_to_views(element_properties)
|
||
|
||
return views
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to get views from elements: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return None
|
||
|
||
def _fetch_element_properties_parallel(self, elements: List, fetch_all: bool = False) -> List[Dict[str, Any]]:
|
||
"""
|
||
并行获取多个 WDA Element 的属性
|
||
|
||
使用 ThreadPoolExecutor 并行发送 HTTP 请求获取每个元素的属性。
|
||
WDA 基于 HTTP,支持并发请求。
|
||
|
||
优化策略:
|
||
- Button、TextField、SecureTextField、SearchField 始终获取完整属性
|
||
- 其他元素类型:
|
||
- 当 fetch_all=False 且总元素数量 > 5 时,只获取 label 和 value 属性
|
||
- 当 fetch_all=True 或总元素数量 <= 5 时,获取所有属性
|
||
|
||
Args:
|
||
elements: 元组列表 [(WDA Element, element_type), ...]
|
||
fetch_all: 是否获取所有元素的完整属性
|
||
|
||
Returns:
|
||
包含所有元素属性的字典列表
|
||
"""
|
||
import concurrent.futures
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
|
||
def fetch_full_properties(elem, idx: int) -> Optional[Dict[str, Any]]:
|
||
"""获取单个元素的所有属性"""
|
||
try:
|
||
props = {
|
||
'element_index': idx, # 记录原始索引
|
||
'element_id': elem.id,
|
||
'bounds': elem.bounds, # Rect 对象
|
||
'label': elem.label,
|
||
'accessible': elem.accessible,
|
||
'name': elem.name,
|
||
'value': elem.value,
|
||
'enabled': bool(elem.enabled),
|
||
'visible': bool(elem.visible),
|
||
}
|
||
return props
|
||
except Exception as e:
|
||
self.logger.debug(f"获取元素完整属性失败 (idx={idx}): {e}")
|
||
return None
|
||
|
||
def fetch_minimal_properties(elem, idx: int) -> Optional[Dict[str, Any]]:
|
||
"""只获取元素的 label 和 value 属性(最小开销)"""
|
||
try:
|
||
props = {
|
||
'element_index': idx, # 记录原始索引
|
||
'label': elem.label,
|
||
'value': elem.value, # 只获取 value,减少 HTTP 请求
|
||
}
|
||
return props
|
||
except Exception as e:
|
||
self.logger.debug(f"获取元素 label 和 value 失败 (idx={idx}): {e}")
|
||
return None
|
||
|
||
elements_count = len(elements)
|
||
|
||
# 定义始终获取完整属性的元素类型
|
||
always_full_types = {'Button', 'TextField', 'SecureTextField', 'SearchField'}
|
||
|
||
# 决定获取策略
|
||
if fetch_all or elements_count <= 5:
|
||
# 获取所有元素的完整属性
|
||
self.logger.debug(f"获取策略: 全部元素完整属性 (count={elements_count})")
|
||
fetch_targets = [(elem, idx, True, elem_type) for idx, (elem, elem_type) in enumerate(elements)]
|
||
else:
|
||
# 优化模式:根据元素类型决定是否获取完整属性
|
||
self.logger.debug(f"获取策略: 根据类型决定属性获取深度 (count={elements_count})")
|
||
fetch_targets = []
|
||
for idx, (elem, elem_type) in enumerate(elements):
|
||
# Button、TextField、SecureTextField、SearchField 始终获取完整属性
|
||
# 其他元素类型只获取 label 和 value
|
||
is_full = elem_type in always_full_types
|
||
fetch_targets.append((elem, idx, is_full, elem_type))
|
||
|
||
# 使用线程池并行获取
|
||
# 优化:根据策略调整并发数,最小开销请求可以更高并发
|
||
max_workers = min(50 if not fetch_all else 30, elements_count)
|
||
properties_list = []
|
||
|
||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||
# 提交任务
|
||
futures = {}
|
||
for elem, idx, is_full, elem_type in fetch_targets:
|
||
if is_full:
|
||
future = executor.submit(fetch_full_properties, elem, idx)
|
||
else:
|
||
future = executor.submit(fetch_minimal_properties, elem, idx)
|
||
futures[future] = (idx, elem_type)
|
||
|
||
# 收集结果(使用 as_completed 提高响应速度)
|
||
for future in concurrent.futures.as_completed(futures):
|
||
try:
|
||
props = future.result(timeout=5)
|
||
if props:
|
||
properties_list.append(props)
|
||
except Exception as e:
|
||
idx, elem_type = futures[future]
|
||
self.logger.debug(f"Future 执行失败 (idx={idx}, type={elem_type}): {e}")
|
||
continue
|
||
|
||
# 按原始索引排序,保持元素顺序
|
||
properties_list.sort(key=lambda p: p.get('element_index', 0))
|
||
|
||
return properties_list
|
||
|
||
def _parse_elements_to_views(self, element_properties: List[Dict]) -> List[Dict[str, Any]]:
|
||
"""
|
||
将 Element 属性列表转换为标准的 View 格式
|
||
|
||
类似于 _parse_wda_source,但用于从 find_elements() 返回的元素。
|
||
注意:find_elements() 不提供层级关系,所以 parent 和 children 都为空。
|
||
|
||
支持两种模式:
|
||
1. 完整属性模式:包含 bounds, label, name, value 等所有属性
|
||
2. 最小属性模式:只包含 value 属性(用于优化性能)
|
||
|
||
Args:
|
||
element_properties: 包含元素属性的字典列表
|
||
|
||
Returns:
|
||
标准格式的视图列表
|
||
"""
|
||
views = []
|
||
foreground_page = self._get_foreground_page() or ""
|
||
|
||
for idx, props in enumerate(element_properties):
|
||
try:
|
||
# 检查是否是最小属性模式(只有 value)
|
||
is_minimal = 'bounds' not in props and 'label' not in props
|
||
|
||
if is_minimal:
|
||
# 最小属性模式:只有 value,使用默认值填充其他字段
|
||
# 这些元素主要用于生成页面唯一标识,不用于交互
|
||
value = props.get('value', '') or ''
|
||
view = {
|
||
"temp_id": props.get('element_index', idx),
|
||
"parent": -1,
|
||
"bounds": [[0, 0], [0, 0]], # 默认边界
|
||
"text": value, # 使用 value 作为 text,确保不同元素有不同的签名
|
||
"resource_id": "",
|
||
"class_name": "Unknown",
|
||
"visible": False, # 标记为不可见(表示未完整加载)
|
||
"enabled": False,
|
||
"clickable": False,
|
||
"scrollable": False,
|
||
"editable": False,
|
||
"children": [],
|
||
"source": "find_elements_minimal",
|
||
# iOS 特有字段
|
||
"ios_name": "",
|
||
"ios_value": value, # 保留 value 用于页面标识
|
||
"accessible": False,
|
||
}
|
||
else:
|
||
# 完整属性模式
|
||
# 获取边界信息
|
||
bounds_rect = props.get('bounds')
|
||
if bounds_rect:
|
||
x, y = bounds_rect.x, bounds_rect.y
|
||
w, h = bounds_rect.width, bounds_rect.height
|
||
bounds = [[int(x), int(y)], [int(x + w), int(y + h)]]
|
||
else:
|
||
bounds = [[0, 0], [0, 0]]
|
||
|
||
# 获取文本内容
|
||
label = props.get('label', '') or ''
|
||
name = props.get('name', '') or ''
|
||
value = props.get('value', '') or ''
|
||
text = label or name or ''
|
||
|
||
# 判断元素属性
|
||
is_button = 'Button' in name
|
||
is_editable = 'TextField' in name or 'SearchField' in name
|
||
|
||
# 构建 ViewDict
|
||
view = {
|
||
"temp_id": props.get('element_index', idx),
|
||
"parent": -1, # find_elements() 不提供层级信息
|
||
"bounds": bounds,
|
||
"text": text,
|
||
"resource_id": props.get('element_id', ''),
|
||
"class_name": name,
|
||
"visible": bool(props.get('visible', True)),
|
||
"enabled": bool(props.get('enabled', True)),
|
||
"clickable": is_button,
|
||
"scrollable": False, # 无法从单个元素判断
|
||
"editable": is_editable,
|
||
"children": [], # find_elements() 不提供层级信息
|
||
"source": "find_elements",
|
||
# iOS 特有字段
|
||
"ios_name": name,
|
||
"ios_value": value,
|
||
"accessible": props.get('accessible', False),
|
||
}
|
||
|
||
views.append(view)
|
||
|
||
except Exception as e:
|
||
self.logger.debug(f"解析元素 {idx} 失败: {e}")
|
||
continue
|
||
|
||
# 生成 view_str(即使是最小属性模式,也能参与页面标识生成)
|
||
if views:
|
||
self._generate_view_strs(views, foreground_page)
|
||
|
||
return views
|
||
|
||
def _get_foreground_page(self) -> Optional[str]:
|
||
"""获取前台页面标识"""
|
||
try:
|
||
current_app = self._wda_client.app_current()
|
||
bundle_id = current_app.get("bundleId", "")
|
||
# iOS 没有 Activity 概念,使用 bundle_id 作为页面标识
|
||
return bundle_id
|
||
except:
|
||
return None
|
||
|
||
def get_device_info(self) -> Dict[str, Any]:
|
||
"""获取设备信息"""
|
||
if self._device_info is None:
|
||
try:
|
||
self._device_info = self._wda_client.status()
|
||
except:
|
||
self._device_info = {}
|
||
return self._device_info
|
||
|
||
def get_ios_version(self) -> str:
|
||
"""获取 iOS 版本"""
|
||
info = self.get_device_info()
|
||
return info.get("os", {}).get("version", "unknown")
|
||
|
||
def get_model_name(self) -> str:
|
||
"""获取设备型号"""
|
||
try:
|
||
device_info = self._wda_client.device_info()
|
||
return device_info.get("name", "iPhone")
|
||
except:
|
||
return "iPhone"
|
||
|
||
def get_model_number(self) -> str:
|
||
"""获取设备型号(兼容 UTG 接口)"""
|
||
return self.get_model_name()
|
||
|
||
def get_sdk_version(self) -> int:
|
||
"""获取 SDK 版本(兼容 UTG 接口)
|
||
|
||
iOS 没有 SDK 版本概念,返回 iOS 主版本号
|
||
"""
|
||
try:
|
||
version = self.get_ios_version()
|
||
# 返回主版本号,如 "18.5" -> 18
|
||
return int(version.split(".")[0])
|
||
except:
|
||
return 0
|
||
|
||
def get_app_pid(self, app_identifier: str) -> int:
|
||
"""获取应用进程 ID(兼容 input_policy 接口)
|
||
|
||
iOS 通过 WDA 无法直接获取 PID,返回 -1
|
||
"""
|
||
# iOS 不支持直接获取 PID
|
||
return -1
|
||
|
||
def get_traffic_domains(self, remote_dir: str) -> Optional[str]:
|
||
"""
|
||
获取 iOS 流量域名日志,对应 TrafficMonitor 的接口。
|
||
|
||
iOS 不通过 ADB,而是直接读取 pcap 工具在本地生成的
|
||
*.flows.csv 文件(TargetDomain 列)并转换为
|
||
TrafficMonitor._parse_content() 期望的格式:
|
||
包名,应用名,域名
|
||
|
||
性能优化:记录上次读取偏移量,每次只读新增行(增量读取)。
|
||
CSV 文件切换时自动重置偏移量。
|
||
|
||
:param remote_dir: 未使用(保留接口兼容),实际路径根据 self.output_dir 确定
|
||
:return: 多行文本 \"包名,应用名,域名\" ,无可用数据时返回 None
|
||
"""
|
||
import csv as csv_module
|
||
|
||
# 确定搜索目录,按优先级排列
|
||
# 实际布局:
|
||
# captured_traffic_dir = .../com_monopoly_.../ (由 ios_test.py 注入)
|
||
# device.output_dir = .../com_monopoly_.../droidbot/
|
||
# flows.csv = .../com_monopoly_.../traffic/xxx.flows.csv
|
||
search_dirs = []
|
||
if self.captured_traffic_dir and os.path.isdir(self.captured_traffic_dir):
|
||
# 优先:captured_traffic_dir/traffic/ 子目录(pcap 实际写入位置)
|
||
sub_traffic = os.path.join(self.captured_traffic_dir, "traffic")
|
||
if os.path.isdir(sub_traffic):
|
||
search_dirs.append(sub_traffic)
|
||
# 备用:captured_traffic_dir 本身
|
||
search_dirs.append(self.captured_traffic_dir)
|
||
if self.output_dir:
|
||
# output_dir 是 droidbot 子目录,pcap traffic 在其父目录的 traffic/ 下
|
||
parent_traffic = os.path.join(os.path.dirname(self.output_dir), "traffic")
|
||
if os.path.isdir(parent_traffic) and parent_traffic not in search_dirs:
|
||
search_dirs.append(parent_traffic)
|
||
traffic_dir = os.path.join(self.output_dir, "traffic")
|
||
if os.path.isdir(traffic_dir) and traffic_dir not in search_dirs:
|
||
search_dirs.append(traffic_dir)
|
||
if self.output_dir not in search_dirs:
|
||
search_dirs.append(self.output_dir) # 最低优先级备用
|
||
|
||
if not search_dirs:
|
||
self.logger.debug("get_traffic_domains: 无有效搜索目录")
|
||
return None
|
||
|
||
# 查找最新的 *.flows.csv(同时递归搜索一层子目录,兼容 pcap 在子目录写文件的情形)
|
||
csv_path = None
|
||
latest_mtime = -1
|
||
for d in search_dirs:
|
||
try:
|
||
for fname in os.listdir(d):
|
||
fpath = os.path.join(d, fname)
|
||
if fname.endswith(".flows.csv"):
|
||
mtime = os.path.getmtime(fpath)
|
||
if mtime > latest_mtime:
|
||
latest_mtime = mtime
|
||
csv_path = fpath
|
||
elif os.path.isdir(fpath):
|
||
# 递归搜索一层子目录(兼容 pcap 放在 traffic/ 子目录)
|
||
try:
|
||
for sub_fname in os.listdir(fpath):
|
||
if sub_fname.endswith(".flows.csv"):
|
||
sub_fpath = os.path.join(fpath, sub_fname)
|
||
mtime = os.path.getmtime(sub_fpath)
|
||
if mtime > latest_mtime:
|
||
latest_mtime = mtime
|
||
csv_path = sub_fpath
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
if not csv_path:
|
||
self.logger.debug("get_traffic_domains: 未找到 .flows.csv 文件")
|
||
return None
|
||
|
||
# 懒初始化增量读取状态
|
||
if not hasattr(self, '_traffic_csv_path'):
|
||
self._traffic_csv_path = None
|
||
self._traffic_csv_offset = 0 # 字节偏移量
|
||
self._traffic_csv_header = None # 列名列表
|
||
|
||
# CSV 文件切换时重置状态
|
||
if csv_path != self._traffic_csv_path:
|
||
self._traffic_csv_path = csv_path
|
||
self._traffic_csv_offset = 0
|
||
self._traffic_csv_header = None
|
||
self.logger.debug(f"get_traffic_domains: 切换到新 CSV: {os.path.basename(csv_path)}")
|
||
|
||
# 增量读取:从上次偏移量开始,只读新增内容
|
||
lines = []
|
||
try:
|
||
with open(csv_path, "r", encoding="utf-8", errors="ignore") as f:
|
||
# 第一次读取:解析表头
|
||
if self._traffic_csv_header is None:
|
||
header_line = f.readline()
|
||
if not header_line:
|
||
return None
|
||
self._traffic_csv_header = [h.strip() for h in header_line.split(',')]
|
||
self._traffic_csv_offset = f.tell()
|
||
else:
|
||
# 跳到上次读取位置
|
||
f.seek(self._traffic_csv_offset)
|
||
|
||
# 只读新增行
|
||
new_content = f.read()
|
||
new_offset = f.tell()
|
||
|
||
if not new_content.strip():
|
||
return None # 无新增内容
|
||
|
||
# 更新偏移量
|
||
self._traffic_csv_offset = new_offset
|
||
|
||
# 解析新增行
|
||
header = self._traffic_csv_header
|
||
try:
|
||
domain_idx = header.index('TargetDomain')
|
||
except ValueError:
|
||
domain_idx = -1
|
||
try:
|
||
bundle_idx = header.index('BundleID')
|
||
except ValueError:
|
||
bundle_idx = -1
|
||
try:
|
||
appname_idx = header.index('AppName')
|
||
except ValueError:
|
||
appname_idx = -1
|
||
|
||
for raw_line in new_content.splitlines():
|
||
raw_line = raw_line.strip()
|
||
if not raw_line:
|
||
continue
|
||
# 简单 CSV 拆分(无引号场景)
|
||
parts = [p.strip() for p in raw_line.split(',')]
|
||
domain = parts[domain_idx] if domain_idx >= 0 and domain_idx < len(parts) else ''
|
||
if not domain:
|
||
continue
|
||
pkg = (parts[bundle_idx] if bundle_idx >= 0 and bundle_idx < len(parts) else '') or (self.bundle_id or '')
|
||
app_name = parts[appname_idx] if appname_idx >= 0 and appname_idx < len(parts) else ''
|
||
# 确保 pkg 和 app_name 本身不含逗号,使输出行严格为三列
|
||
# _parse_content 依赖 parts[2] 取 domain,若中间字段含逗号会导致偏移
|
||
pkg_safe = pkg.replace(',', '_')
|
||
app_name_safe = app_name.replace(',', '_')
|
||
lines.append(f"{pkg_safe},{app_name_safe},{domain}")
|
||
|
||
except Exception as e:
|
||
self.logger.warning(f"get_traffic_domains: 读取 flows.csv 失败: {e}")
|
||
return None
|
||
|
||
if not lines:
|
||
return None
|
||
|
||
self.logger.debug(
|
||
f"get_traffic_domains: 从 {os.path.basename(csv_path)} "
|
||
f"读取 {len(lines)} 条新增流量记录"
|
||
)
|
||
return "\n".join(lines)
|