AI_UIAutomation/drivers/android-driver.ts

427 lines
19 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 折中。**控制阶段**无此顾虑(控现有卡、点击后都有显式 sleep)→ 可经 WAIT_IDLE_TIMEOUT=100 大幅提速。
const waitIdle = Number(process.env.WAIT_IDLE_TIMEOUT ?? 1000);
await this.request('POST', `/session/${this.sessionId}/appium/settings`, {
settings: { waitForIdleTimeout: waitIdle, actionAcknowledgmentTimeout: 0, keyInjectionDelay: 0 },
}).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++) {
const source = await this.getSource();
const hasTabs = source.includes('content-desc="Home"') && source.includes('content-desc="Profile"');
if (hasTabs) {
const homeTab = await this.findElementRaw('accessibility id', 'Home');
if (homeTab) await this.tapElement(homeTab);
await new Promise(r => setTimeout(r, 500));
return true;
}
// 方法1(浮层识别):底部 tab 栏(Home/Profile)读不到时,先分清"首页被快捷控制浮层/遮罩盖住"还是"在更深页面"。
// 首页被盖住的特征 = 仍能读到首页卡片(nameText/nameTextMeter)。此时**绝不能按 BACK** ——
// RN 首页根部的 BACK 不被底部 sheet 消费 → Android finish Activity → App 退到手机桌面
// (正是空净切模式"退桌面→后续读到 0 卡片→412s 超时找不到卡片"的根因)。
// 改为收浮层(清弹框 + 点顶部遮罩暗区收起 sheet)后重判;只有确在更深页面(无首页卡片)才安全按 BACK。
const hasHomeCards = /resource-id="[^"]*\/(?:nameText|nameTextMeter)"/.test(source);
if (hasHomeCards) {
await this.dismissPopupIfPresent().catch(() => {});
const sz = await this.getWindowSize().catch(() => ({ width: 1080, height: 2400 }));
await this.tap(Math.round(sz.width * 0.5), Math.round(sz.height * 0.08)); // 点顶部遮罩暗区收起底部 sheet
await new Promise(r => setTimeout(r, 700));
continue;
}
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', '发现新设备', '添加至', // 三星系统层的设备发现弹框
// App 重启后偶发弹框(更新/评分/订阅)常只有一个"Later"类关闭键、文案不含上面任何 trigger →
// 把"Later"家族本身作为触发信号(有这类按钮=有可关弹框),否则循环直接 break、'Later' 点不到。
'Later', 'Maybe Later', 'Not Now', 'No Thanks', 'No, Thanks',
'更新', '升级', '新功能', '评分', '评价', '提示', '通知', '订阅', '试用', '体验', '优惠', '连接失败', '请注意', '温馨提示', '稍后', '以后再说',
];
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;
}
}