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; 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); 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 { 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 { 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 { 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 }; } 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 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; } }