AI_UIAutomation/drivers/android-driver.ts

323 lines
12 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;
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);
await this.request('POST', `/session/${this.sessionId}/actions`, {
actions: [{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: Math.round(fromX), y: Math.round(fromY) },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerMove', duration: durationMs, x: Math.round(toX), y: Math.round(toY) },
{ type: 'pointerUp', button: 0 },
],
}],
});
}
async scrollDown(distance = 300): Promise<void> {
const size = await this.getWindowSize();
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.getWindowSize();
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 };
}
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 source = await this.getSource();
if (source.includes('android.app.Dialog') || source.includes('permission') ||
source.includes('Upgrade') || source.includes("What's New") ||
source.includes('AlertDialog') || source.includes('Please Note') ||
source.includes('Restart') || source.includes('Got it')) {
const dismissTexts = ['OK', 'Got it', 'Close', 'Cancel', 'Confirm', 'Allow',
'Later', 'Skip', 'Done', 'ALLOW', 'DENY', 'Not Now'];
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));
return true;
}
}
}
return false;
}
}