AI_UIAutomation/drivers/android-driver.ts

408 lines
17 KiB
TypeScript

import { DeviceDriver, ElementLocator, Rect, Platform } from './types';
import { APP_CONFIG } from '../config/app.config';
export class AndroidDriver implements DeviceDriver {
readonly platform: Platform = 'android';
private host: string;
private port: number;
private sessionId: string | null = null;
private baseUrl: string;
constructor(host = 'localhost', port = 4723) {
this.host = host;
this.port = port;
this.baseUrl = `http://${host}:${port}`;
}
private get sessionUrl(): string {
if (!this.sessionId) throw new Error('No active Appium session');
return `${this.baseUrl}/session/${this.sessionId}`;
}
private async request(method: string, path: string, body?: any): Promise<any> {
const url = path.startsWith('/') ? `${this.baseUrl}${path}` : path;
const opts: RequestInit = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) opts.body = JSON.stringify(body);
const resp = await fetch(url, opts);
const json = await resp.json();
if (json.value && json.value.error) {
throw new Error(`Appium: ${json.value.message || json.value.error}`);
}
return json.value;
}
async createSession(): Promise<void> {
const capabilities = {
alwaysMatch: {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:noReset': true,
'appium:autoLaunch': false,
'appium:newCommandTimeout': 300,
'appium:uiautomator2ServerInstallTimeout': 60000,
},
};
const result = await this.request('POST', '/session', { capabilities });
this.sessionId = result.sessionId;
// 首页有实时读数(温湿度)持续刷新,UI 永不 idle → 默认 waitForIdleTimeout(10s)会让
// 每次 getSource/查找都白等满。但设为 0 又会在添加流程的连续跳转页抓到"未渲染完"的页面
// (品类网格没加载完就误判设备缺失→狂滑)。折中 1s:既不死等 10s,也给跳转页一点渲染时间。
await this.request('POST', `/session/${this.sessionId}/appium/settings`, {
settings: { waitForIdleTimeout: 1000 },
}).catch(() => { /* 老版本 driver 不支持则忽略 */ });
await this.request('POST', `/session/${this.sessionId}/appium/device/activate_app`, { appId: APP_CONFIG.android.appPackage });
await new Promise(r => setTimeout(r, 3000));
// 启动后集中清一次启动弹框(SmartThings"发现新设备"、Tips/Fix Now、更新/评分等),
// 避免每个流程点首页按钮前都各自处理。多清两轮以防叠加弹框。
for (let i = 0; i < 2; i++) {
const cleared = await this.dismissPopupIfPresent().catch(() => false);
if (!cleared) break;
await new Promise(r => setTimeout(r, 800));
}
}
async destroySession(): Promise<void> {
if (!this.sessionId) return;
await this.request('DELETE', `/session/${this.sessionId}`);
this.sessionId = null;
}
async activateApp(appId: string): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/appium/device/activate_app`, { appId });
await new Promise(r => setTimeout(r, 2000));
}
/** 重启 App(terminate + activate)复位状态:用例异常失败后调用,避免脏状态拖垮后续用例。 */
async restartApp(): Promise<void> {
const pkg = APP_CONFIG.android.appPackage;
try { await this.request('POST', `/session/${this.sessionId}/appium/device/terminate_app`, { appId: pkg }); } catch { /* 已退则忽略 */ }
await new Promise(r => setTimeout(r, 1500));
await this.request('POST', `/session/${this.sessionId}/appium/device/activate_app`, { appId: pkg });
await new Promise(r => setTimeout(r, 4000));
// 重启=冷启动,弹框多且可能延迟/叠加(隐私/更新/Tips/SmartThings/评分)→ 多轮清,每轮留足时间等延迟弹框冒出。
for (let i = 0; i < 5; i++) {
const cleared = await this.dismissPopupIfPresent().catch(() => false);
await new Promise(r => setTimeout(r, 1200));
if (!cleared && i >= 2) break; // 至少清 3 轮,之后连续无弹框才停(容忍延迟弹框)
}
}
async findElement(locator: ElementLocator): Promise<string | null> {
if (!locator.android) return null;
return this.findElementRaw(locator.android.using, locator.android.value);
}
async findElements(locator: ElementLocator): Promise<string[]> {
if (!locator.android) return [];
return this.findElementsRaw(locator.android.using, locator.android.value);
}
private mapStrategy(using: string, value: string): { using: string; value: string } {
if (using === 'name' || using === 'text') {
return { using: '-android uiautomator', value: `new UiSelector().text("${value}")` };
}
if (using === 'accessibility id' || using === 'content-desc') {
return { using: 'accessibility id', value };
}
if (using === 'id') {
return { using: 'id', value: value.includes(':id/') ? value : `com.theswitchbot.switchbot:id/${value}` };
}
if (using === 'predicate string') {
const textMatch = value.match(/name\s*(?:==|CONTAINS)\s*"([^"]+)"/);
if (textMatch) {
const text = textMatch[1];
const contains = value.includes('CONTAINS');
return { using: '-android uiautomator', value: contains
? `new UiSelector().textContains("${text}")`
: `new UiSelector().text("${text}")` };
}
return { using: '-android uiautomator', value: `new UiSelector().textContains("${value}")` };
}
if (using === 'class name') {
const classMap: Record<string, string> = {
'XCUIElementTypeTextField': 'android.widget.EditText',
'XCUIElementTypeSecureTextField': 'android.widget.EditText',
'XCUIElementTypeStaticText': 'android.widget.TextView',
'XCUIElementTypeButton': 'android.widget.Button',
'XCUIElementTypeSwitch': 'android.widget.Switch',
'XCUIElementTypeCell': 'android.widget.LinearLayout',
'XCUIElementTypeImage': 'android.widget.ImageView',
};
const androidClass = classMap[value] || value;
return { using: 'class name', value: androidClass };
}
return { using, value };
}
async findElementRaw(using: string, value: string): Promise<string | null> {
try {
const mapped = this.mapStrategy(using, value);
const result = await this.request('POST', `/session/${this.sessionId}/element`, mapped);
return result.ELEMENT || result['element-6066-11e4-a52e-4f735466cecf'] || null;
} catch {
return null;
}
}
async findElementsRaw(using: string, value: string): Promise<string[]> {
try {
const mapped = this.mapStrategy(using, value);
const result = await this.request('POST', `/session/${this.sessionId}/elements`, mapped);
if (!Array.isArray(result)) return [];
return result.map((e: any) => e.ELEMENT || e['element-6066-11e4-a52e-4f735466cecf']).filter(Boolean);
} catch {
return [];
}
}
async getElementRect(elementId: string): Promise<Rect> {
const result = await this.request('GET', `/session/${this.sessionId}/element/${elementId}/rect`);
return { x: result.x, y: result.y, width: result.width, height: result.height };
}
async getElementAttribute(elementId: string, attr: string): Promise<string> {
const result = await this.request('GET', `/session/${this.sessionId}/element/${elementId}/attribute/${attr}`);
return result || '';
}
async tap(x: number, y: number): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/actions`, {
actions: [{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: Math.round(x), y: Math.round(y) },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 100 },
{ type: 'pointerUp', button: 0 },
],
}],
});
}
async doubleTap(x: number, y: number): Promise<void> {
await this.tap(x, y);
await new Promise(r => setTimeout(r, 100));
await this.tap(x, y);
}
async longPress(x: number, y: number, duration = 2): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/actions`, {
actions: [{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: Math.round(x), y: Math.round(y) },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: Math.round(duration * 1000) },
{ type: 'pointerUp', button: 0 },
],
}],
});
}
async tapElement(elementId: string): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/click`, {});
}
async clickElement(elementId: string): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/click`, {});
}
async typeText(elementId: string, text: string): Promise<void> {
await this.tapElement(elementId);
await new Promise(r => setTimeout(r, 300));
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/value`, { text });
}
/** Android 无原生 PickerWheel(用坐标盲滑)→ 兜底走 typeText。 */
async setPickerWheelValue(elementId: string, value: string): Promise<void> {
await this.typeText(elementId, value);
}
async clearText(elementId: string): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/clear`, {});
}
async swipe(fromX: number, fromY: number, toX: number, toY: number, duration = 0.5): Promise<void> {
const durationMs = Math.round(duration * 1000);
const body = {
actions: [{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(fromX), y: Math.round(fromY) },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerMove', duration: durationMs, origin: 'viewport', x: Math.round(toX), y: Math.round(toY) },
{ type: 'pointerUp', button: 0 },
],
}],
};
// UIA2 偶发 "Unable to perform W3C actions" 瞬时抖动 → 清理手势链后重试一次
try {
await this.request('POST', `/session/${this.sessionId}/actions`, body);
} catch (e: any) {
if (!/W3C actions/i.test(e.message || '')) throw e;
await this.request('DELETE', `/session/${this.sessionId}/actions`).catch(() => {});
await new Promise((r) => setTimeout(r, 500));
await this.request('POST', `/session/${this.sessionId}/actions`, body);
}
}
async scrollDown(distance = 300): Promise<void> {
const size = await this.getWindowSizeCached();
const startX = size.width / 2;
const startY = size.height / 2;
await this.swipe(startX, startY, startX, startY - distance, 0.5);
}
async scrollUp(distance = 300): Promise<void> {
const size = await this.getWindowSizeCached();
const startX = size.width / 2;
const startY = size.height / 2;
await this.swipe(startX, startY, startX, startY + distance, 0.5);
}
async goBack(): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/back`, {});
}
async getSource(): Promise<string> {
return await this.request('GET', `/session/${this.sessionId}/source`);
}
async getWindowSize(): Promise<{ width: number; height: number }> {
const result = await this.request('GET', `/session/${this.sessionId}/window/rect`);
return { width: result.width, height: result.height };
}
private _winSize: { width: number; height: number } | null = null;
/** 窗口尺寸缓存:滚动等高频调用不必每次往返查询(屏幕尺寸一个会话内不变)。 */
async getWindowSizeCached(): Promise<{ width: number; height: number }> {
if (!this._winSize) this._winSize = await this.getWindowSize();
return this._winSize;
}
async screenshot(): Promise<string> {
return await this.request('GET', `/session/${this.sessionId}/screenshot`);
}
async tapByLocator(locator: ElementLocator): Promise<boolean> {
const elemId = await this.findElement(locator);
if (!elemId) return false;
await this.tapElement(elemId);
return true;
}
async waitForElement(locator: ElementLocator, timeoutMs = 10000): Promise<string | null> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const elemId = await this.findElement(locator);
if (elemId) return elemId;
await new Promise(r => setTimeout(r, 500));
}
return null;
}
async isElementVisible(locator: ElementLocator): Promise<boolean> {
const elemId = await this.findElement(locator);
if (!elemId) return false;
const displayed = await this.getElementAttribute(elemId, 'displayed');
return displayed === 'true';
}
async findBotCard(): Promise<string | null> {
return this.findDeviceCard('Bot');
}
async findDeviceCard(deviceName: string): Promise<string | null> {
let el = await this.findElementRaw('-android uiautomator',
`new UiSelector().textContains("${deviceName}")`);
if (el) return el;
el = await this.findElementRaw('-android uiautomator',
`new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().textContains("${deviceName}"))`);
return el;
}
async isOnHomepage(): Promise<boolean> {
const source = await this.getSource();
return source.includes('content-desc="Home"') && source.includes('content-desc="Profile"');
}
async goBackToHomepage(): Promise<boolean> {
for (let i = 0; i < 6; i++) {
if (await this.isOnHomepage()) {
const homeTab = await this.findElementRaw('accessibility id', 'Home');
if (homeTab) await this.tapElement(homeTab);
await new Promise(r => setTimeout(r, 500));
return true;
}
await this.goBack();
await new Promise(r => setTimeout(r, 800));
}
return await this.isOnHomepage();
}
async dismissPopupIfPresent(): Promise<boolean> {
let bannerClosed = false;
// —— 网络异常横幅(BLE-only/离线瞬时云失败时顶部橙色条 "Network error, please check network connection."):
// 它**不是对话框**、关闭键是右侧 X 图标(无文字),普通弹框文字按钮匹配不到。横幅在时占顶部空间 +
// 关联首页列表刷新churn,会导致卡片查找"卡片明明可见却扫不到设备"(实测 curtain 控制即此)。
// 检测到横幅文字 → 取其 bounds → 点右缘 X 关闭。严格限定仅顶部区域,避免误点 header 的 +/… 按钮。
try {
const bsrc = await this.getSource();
const node = bsrc.split('<').find(n => /check network connection/i.test(n) && /bounds="\[/.test(n));
const b = node && node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
if (b) {
const y1 = +b[2], y2 = +b[4];
const sz = await this.getWindowSize();
if (y2 < sz.height * 0.3 && y1 > sz.height * 0.02) { // 仅横幅所在顶部条带(避开最顶 header)
await this.tap(Math.round(sz.width * 0.93), Math.round((y1 + y2) / 2)); // 右缘 X
await new Promise(r => setTimeout(r, 800));
bannerClosed = true;
}
}
} catch { /* 横幅处理失败不阻塞后续弹框清理 */ }
// 关闭按钮(只点"否定/关闭"类,避免误点"更新/同意"推进);中英都覆盖
const dismissTexts = [
'Cancel', 'Close', 'Later', 'Maybe Later', 'Not Now', 'No Thanks', 'No, Thanks', 'Dismiss', 'Skip', 'Ignore',
'Got it', 'OK', 'I know', 'I Know', 'Done', 'Allow', 'ALLOW', 'DENY', 'While using the app',
'取消', '关闭', '稍后', '以后再说', '暂不', '暂不更新', '忽略', '跳过', '我知道了', '知道了', '好的', '允许', '不用了',
'不添加', // 三星 SmartThings"发现新设备…是否添加"系统弹框
];
// 触发:对话框/常见弹框关键字(扩充:更新/评分/新功能/通知/试用/优惠/连接失败 等)
const triggers = [
'android.app.Dialog', 'AlertDialog', 'permission', 'Upgrade', 'Update', "What's New", 'New Feature',
'Please Note', 'Restart', 'Got it', 'Rate', 'Review', 'Reminder', 'Notification', 'Subscribe', 'Trial', 'Welcome', 'Tips', 'Fix Now',
'Failed to connect', 'connect to your device', 'Connection failed', 'Try Again',
'SmartThings', '发现新设备', '添加至', // 三星系统层的设备发现弹框
'更新', '升级', '新功能', '评分', '评价', '提示', '通知', '订阅', '试用', '体验', '优惠', '连接失败', '请注意', '温馨提示',
];
let dismissed = false;
// 循环清掉叠加的多个弹框(最多 4 个)
for (let round = 0; round < 4; round++) {
const source = await this.getSource();
if (!triggers.some(t => source.includes(t))) break;
let hit = false;
for (const text of dismissTexts) {
const el = await this.findElementRaw('-android uiautomator', `new UiSelector().text("${text}")`);
if (el) {
await this.tapElement(el);
await new Promise(r => setTimeout(r, 1000));
dismissed = true; hit = true;
break;
}
}
if (!hit) break; // 有触发词但没找到可点按钮,停止避免空转
}
return dismissed || bannerClosed;
}
}