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 { 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 { 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 { if (!this.sessionId) return; await this.request('DELETE', `/session/${this.sessionId}`); this.sessionId = null; } async activateApp(appId: string): Promise { await this.request('POST', `/session/${this.sessionId}/appium/device/activate_app`, { appId }); await new Promise(r => setTimeout(r, 2000)); } async findElement(locator: ElementLocator): Promise { if (!locator.android) return null; return this.findElementRaw(locator.android.using, locator.android.value); } async findElements(locator: ElementLocator): Promise { 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 = { '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 { 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 { 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 { 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 { const result = await this.request('GET', `/session/${this.sessionId}/element/${elementId}/attribute/${attr}`); return result || ''; } async tap(x: number, y: number): Promise { 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 { 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 { 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 { await this.request('POST', `/session/${this.sessionId}/element/${elementId}/click`, {}); } async clickElement(elementId: string): Promise { await this.request('POST', `/session/${this.sessionId}/element/${elementId}/click`, {}); } async typeText(elementId: string, text: string): Promise { 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 { await this.request('POST', `/session/${this.sessionId}/element/${elementId}/clear`, {}); } async swipe(fromX: number, fromY: number, toX: number, toY: number, duration = 0.5): Promise { 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 { 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 { 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 { await this.request('POST', `/session/${this.sessionId}/back`, {}); } async getSource(): Promise { 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 { return await this.request('GET', `/session/${this.sessionId}/screenshot`); } async tapByLocator(locator: ElementLocator): Promise { const elemId = await this.findElement(locator); if (!elemId) return false; await this.tapElement(elemId); return true; } async waitForElement(locator: ElementLocator, timeoutMs = 10000): Promise { 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 { const elemId = await this.findElement(locator); if (!elemId) return false; const displayed = await this.getElementAttribute(elemId, 'displayed'); return displayed === 'true'; } async findBotCard(): Promise { return this.findDeviceCard('Bot'); } async findDeviceCard(deviceName: string): Promise { 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 { const source = await this.getSource(); return source.includes('content-desc="Home"') && source.includes('content-desc="Profile"'); } async goBackToHomepage(): Promise { 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 { // 关闭按钮(只点"否定/关闭"类,避免误点"更新/同意"推进);中英都覆盖 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; } }