257 lines
7.6 KiB
Python
257 lines
7.6 KiB
Python
"""
|
||
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
|
||
|