""" Abstract Device State Base Class Platform-agnostic device state interface. """ from abc import ABC, abstractmethod from typing import Optional, Dict, Any, List, Set try: from typing import TypedDict except ImportError: from typing_extensions import TypedDict import os class ViewDict(TypedDict, total=False): """ 统一的视图/控件字典结构 所有平台(Android/iOS/Web)和来源(Accessibility Tree/CV) 都必须输出此格式,确保事件处理代码可以统一处理。 """ # === 必需字段 === bounds: List[List[int]] # [[x1, y1], [x2, y2]] 坐标 text: str # 文本内容 visible: bool # 是否可见 enabled: bool # 是否启用 clickable: bool # 是否可点击 editable: bool # 是否可编辑 scrollable: bool # 是否可滚动 children: List[int] # 子节点索引列表 view_str: str # 唯一标识符(自动生成) # === 可选字段 === content_description: str # 无障碍描述 resource_id: str # 资源ID class_name: str # 控件类名(避免 'class' 关键字) temp_id: int # 临时索引 parent: int # 父节点索引 source: str # 来源: 'accessibility' 或 'cv' # === 扩展字段(特定平台可能需要)=== signature: str # 内容签名 long_clickable: bool # 是否可长按 checkable: bool # 是否可勾选 checked: bool # 是否已勾选 selected: bool # 是否已选中 class AbstractDeviceState(ABC): """ 设备状态抽象基类 定义了设备状态的标准接口,包括: - 视图/控件信息 - 状态标识 - 可能的输入事件 - 前台活动信息 """ def __init__(self, device: 'AbstractDevice', tag: Optional[str] = None, screenshot_path: Optional[str] = None): """ 初始化设备状态 :param device: 设备对象 :param tag: 状态标签 :param screenshot_path: 截图路径 """ self.device = device self._screenshot_path = screenshot_path if tag is None: from datetime import datetime tag = datetime.now().strftime("%Y-%m-%d_%H%M%S") self.tag = tag # 缓存的状态信息 self._state_str = None self._possible_events = None self._views = None # ==================== 视图信息 ==================== @property @abstractmethod def views(self) -> List[Dict[str, Any]]: """ 获取当前界面的所有视图/控件元素 :return: 视图字典列表,每个字典包含控件的属性信息 """ pass @property def view_tree(self) -> Dict[str, Any]: """ 获取视图树结构(可选实现) :return: 视图树字典 """ return {} @property def cv_views(self) -> List['ViewDict']: """ 获取 CV 检测到的视图列表 :return: CV 视图列表,符合 ViewDict 格式 """ return [] # ==================== 状态标识 ==================== @property @abstractmethod def state_str(self) -> str: """ 获取状态的唯一标识字符串 :return: 状态标识字符串(通常是 MD5 哈希) """ pass @property def structure_str(self) -> str: """ 获取状态的结构标识(忽略内容) :return: 结构标识字符串 """ return self.state_str @property def search_content(self) -> str: """ 获取用于搜索的文本内容(可选,用于 UTG 可视化) :return: 搜索内容字符串,默认返回空字符串 """ return "" # ==================== 输入事件 ==================== @abstractmethod def get_possible_input(self) -> List['AbstractInputEvent']: """ 获取当前状态可能的输入事件列表 :return: 可能的输入事件列表 """ pass # ==================== 活动/页面信息 ==================== @property @abstractmethod def foreground_page(self) -> Optional[str]: """ 获取前台页面/窗口标识 :return: 前台页面名称,如果无法获取则返回 None """ pass # ==================== 截图 ==================== @property def screenshot_path(self) -> Optional[str]: """获取截图路径""" return self._screenshot_path @screenshot_path.setter def screenshot_path(self, value: str): """设置截图路径""" self._screenshot_path = value # ==================== 屏幕尺寸 ==================== @property def width(self) -> int: """获取屏幕宽度""" return self.device.get_width() @property def height(self) -> int: """获取屏幕高度""" return self.device.get_height() # ==================== 序列化 ==================== @abstractmethod def to_dict(self) -> Dict[str, Any]: """ 序列化为字典 :return: 状态信息字典 """ pass def to_json(self) -> str: """ 序列化为 JSON 字符串 :return: JSON 字符串 """ import json return json.dumps(self.to_dict(), indent=2) # ==================== 状态保存 ==================== def save2dir(self, output_dir: Optional[str] = None) -> None: """ 保存状态到目录,使用 flush + fsync 确保数据落盘 :param output_dir: 输出目录,默认使用设备的输出目录 """ try: if output_dir is None: if self.device.output_dir is None: return output_dir = os.path.join(self.device.output_dir, "states") if not os.path.exists(output_dir): os.makedirs(output_dir) # 保存状态 JSON dest_state_json_path = os.path.join(output_dir, f"state_{self.tag}.json") with open(dest_state_json_path, "w") as f: f.write(self.to_json()) f.flush() os.fsync(f.fileno()) # 复制截图 if self.screenshot_path and os.path.exists(self.screenshot_path): import shutil ext = os.path.splitext(self.screenshot_path)[1] dest_screenshot_path = os.path.join(output_dir, f"screen_{self.tag}{ext}") if os.path.abspath(self.screenshot_path) != os.path.abspath(dest_screenshot_path): shutil.copyfile(self.screenshot_path, dest_screenshot_path) self._screenshot_path = dest_screenshot_path except Exception as e: self.device.logger.error(f"Error saving state: {e}") # ==================== 辅助方法 ==================== @staticmethod def get_view_center(view_dict: Dict[str, Any]) -> tuple: """ 获取视图中心点坐标 :param view_dict: 视图字典 :return: (x, y) 坐标元组 """ bounds = view_dict.get('bounds', [[0, 0], [0, 0]]) return (bounds[0][0] + bounds[1][0]) / 2, (bounds[0][1] + bounds[1][1]) / 2