359 lines
14 KiB
TypeScript
359 lines
14 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));
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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> {
|
|
// 关闭按钮(只点"否定/关闭"类,避免误点"更新/同意"推进);中英都覆盖
|
|
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',
|
|
'取消', '关闭', '稍后', '以后再说', '暂不', '暂不更新', '忽略', '跳过', '我知道了', '知道了', '好的', '允许', '不用了',
|
|
];
|
|
// 触发:对话框/常见弹框关键字(扩充:更新/评分/新功能/通知/试用/优惠/连接失败 等)
|
|
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',
|
|
'Failed to connect', 'connect to your device', 'Connection failed', 'Try Again',
|
|
'更新', '升级', '新功能', '评分', '评价', '提示', '通知', '订阅', '试用', '体验', '优惠', '连接失败', '请注意', '温馨提示',
|
|
];
|
|
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;
|
|
}
|
|
}
|