droideer
Version:
The Puppeteer for Android - Control Android devices with familiar web automation syntax
457 lines (398 loc) • 17.2 kB
JavaScript
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export class ADB {
constructor(deviceId = null) {
this.deviceId = deviceId;
this.prefix = deviceId ? `adb -s ${deviceId}` : 'adb';
this._capabilities = null;
}
async execute(command, options = {}) {
const timeout = options.timeout || 30000;
const fullCommand = `${this.prefix} ${command}`;
try {
const { stdout, stderr } = await execAsync(fullCommand, { timeout });
// Some ADB commands return info via stderr that's not actually an error
if (stderr && !this._isWarning(stderr)) {
throw new Error(stderr);
}
return stdout.trim();
} catch (error) {
if (error.code === 'ETIMEDOUT') {
throw new Error(`ADB command timed out after ${timeout}ms: ${command}`);
}
throw new Error(`ADB command failed: ${error.message}`);
}
}
_isWarning(stderr) {
const warnings = [
'Warning:',
'adb: failed to install',
'* daemon not running',
'* daemon started successfully'
];
return warnings.some(warning => stderr.includes(warning));
}
async shell(command, options = {}) {
// Escape command properly for shell
const escapedCommand = command.replace(/"/g, '\\"');
return this.execute(`shell "${escapedCommand}"`, options);
}
// Device management
async getDevices() {
const output = await this.execute('devices -l');
const lines = output.split('\n').slice(1); // Skip header
return lines
.filter(line => line.trim() && !line.includes('daemon'))
.map(line => {
const parts = line.split(/\s+/);
return {
id: parts[0],
state: parts[1],
info: parts.slice(2).join(' ')
};
});
}
async getDeviceInfo() {
if (!this._capabilities) {
const brand = await this.shell('getprop ro.product.brand').catch(() => 'Unknown');
const model = await this.shell('getprop ro.product.model').catch(() => 'Unknown');
const version = await this.shell('getprop ro.build.version.release').catch(() => 'Unknown');
const sdk = await this.shell('getprop ro.build.version.sdk').catch(() => 'Unknown');
this._capabilities = { brand, model, version, sdk };
}
return this._capabilities;
}
async isConnected() {
try {
const state = await this.execute('get-state');
return state === 'device';
} catch (error) {
return false;
}
}
// App management
async isPackageInstalled(packageName) {
try {
const result = await this.shell(`pm list packages ${packageName}`);
return result.includes(`package:${packageName}`);
} catch (error) {
return false;
}
}
async getPackageInfo(packageName) {
try {
const info = await this.shell(`dumpsys package ${packageName} | head -50`);
const lines = info.split('\n');
const packageInfo = {
package: packageName,
versionName: null,
versionCode: null,
enabled: true,
installed: true
};
for (const line of lines) {
if (line.includes('versionName=')) {
packageInfo.versionName = line.split('versionName=')[1]?.split(' ')[0];
}
if (line.includes('versionCode=')) {
packageInfo.versionCode = line.split('versionCode=')[1]?.split(' ')[0];
}
}
return packageInfo;
} catch (error) {
return null;
}
}
async getLauncherActivity(packageName) {
try {
// Multiple methods to find launcher activity
const methods = [
// Method 1: Use cmd package resolve-activity
async () => {
const result = await this.shell(`cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER ${packageName}`);
const match = result.match(/([a-zA-Z0-9_.]+\/[a-zA-Z0-9_.]+)/);
return match ? match[1] : null;
},
// Method 2: Parse dumpsys package
async () => {
const dump = await this.shell(`dumpsys package ${packageName} | grep -A 5 "android.intent.action.MAIN"`);
const lines = dump.split('\n');
for (const line of lines) {
if (line.includes('android.intent.category.LAUNCHER')) {
const prevLine = lines[lines.indexOf(line) - 1];
const match = prevLine?.match(/([a-zA-Z0-9_.]+)\/([a-zA-Z0-9_.]+)/);
if (match) return `${match[1]}/${match[2]}`;
}
}
return null;
},
// Method 3: Use pm dump with specific parsing
async () => {
const dump = await this.shell(`pm dump ${packageName} | grep -B 2 -A 2 "android.intent.category.LAUNCHER"`);
const match = dump.match(/Activity #\d+:\s*([a-zA-Z0-9_.]+\/[a-zA-Z0-9_.]+)/);
return match ? match[1] : null;
}
];
for (const method of methods) {
try {
const result = await method();
if (result) return result;
} catch (error) {
continue;
}
}
return null;
} catch (error) {
console.warn('Error getting launcher activity:', error.message);
return null;
}
}
async startApp(packageName) {
console.log(`🚀 Starting app: ${packageName}`);
// Verify package is installed
const isInstalled = await this.isPackageInstalled(packageName);
if (!isInstalled) {
throw new Error(`Package ${packageName} is not installed`);
}
// Try multiple launch methods in order of reliability
const launchMethods = [
{
name: 'Direct Activity Launch',
action: async () => {
const activity = await this.getLauncherActivity(packageName);
if (!activity) throw new Error('No launcher activity found');
const result = await this.shell(`am start -n ${activity}`);
if (result.includes('Error') || result.includes('Exception')) {
throw new Error(result);
}
return result;
}
},
{
name: 'Monkey Launch',
action: async () => {
const result = await this.shell(`monkey -p ${packageName} -c android.intent.category.LAUNCHER 1`);
if (result.includes('Error') || result.includes('No activities found') || result.includes('killed')) {
throw new Error(result);
}
return result;
}
},
{
name: 'Intent Launch',
action: async () => {
const result = await this.shell(`am start -a android.intent.action.MAIN -c android.intent.category.LAUNCHER ${packageName}`);
if (result.includes('Error') || result.includes('No Activity found')) {
throw new Error(result);
}
return result;
}
},
{
name: 'Force Launch',
action: async () => {
return await this.shell(`am start --user 0 -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -f 0x10200000 ${packageName}`);
}
}
];
for (const method of launchMethods) {
try {
console.log(`📱 Trying ${method.name}...`);
const result = await method.action();
// Wait for app to actually start
await this._waitForAppStart(packageName, 5000);
console.log(`✅ App started successfully using ${method.name}`);
return result;
} catch (error) {
console.log(`❌ ${method.name} failed: ${error.message}`);
continue;
}
}
// All methods failed, provide diagnostic info
await this._provideDiagnostics(packageName);
throw new Error(`Unable to start app ${packageName}. All launch methods failed.`);
}
async _waitForAppStart(packageName, timeout = 10000) {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
const currentActivity = await this.getCurrentActivity();
if (currentActivity?.package === packageName) {
return true;
}
} catch (error) {
// Continue waiting
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error(`App ${packageName} did not start within ${timeout}ms`);
}
async _provideDiagnostics(packageName) {
console.log('🔍 Diagnostic Information:');
try {
const packageInfo = await this.getPackageInfo(packageName);
console.log('📦 Package Info:', packageInfo);
const launcherActivity = await this.getLauncherActivity(packageName);
console.log('🎯 Launcher Activity:', launcherActivity || 'Not found');
const isEnabled = await this.shell(`pm list packages -e ${packageName}`);
console.log('⚡ Package Enabled:', isEnabled.includes(packageName));
} catch (error) {
console.log('❌ Could not gather diagnostics:', error.message);
}
}
async stopApp(packageName) {
return this.shell(`am force-stop ${packageName}`);
}
async clearApp(packageName) {
return this.shell(`pm clear ${packageName}`);
}
async getCurrentActivity() {
try {
// Try multiple methods to get current activity
const methods = [
async () => {
const output = await this.shell('dumpsys window | grep mCurrentFocus');
const match = output.match(/mCurrentFocus=Window{[^}]* ([^/]+)\/([^}]+)}/);
return match ? { package: match[1], activity: match[2] } : null;
},
async () => {
const output = await this.shell('dumpsys activity activities | grep "mResumedActivity"');
const match = output.match(/([a-zA-Z0-9_.]+)\/([a-zA-Z0-9_.]+)/);
return match ? { package: match[1], activity: match[2] } : null;
}
];
for (const method of methods) {
try {
const result = await method();
if (result) return result;
} catch (error) {
continue;
}
}
return null;
} catch (error) {
return null;
}
}
// Input methods
async tap(x, y) {
return this.shell(`input tap ${x} ${y}`);
}
async swipe(x1, y1, x2, y2, duration = 300) {
return this.shell(`input swipe ${x1} ${y1} ${x2} ${y2} ${duration}`);
}
async type(text) {
// Handle special characters and spaces
const escapedText = text
.replace(/'/g, "\\'")
.replace(/ /g, '%s')
.replace(/&/g, '\\&')
.replace(/\$/g, '\\$');
return this.shell(`input text '${escapedText}'`);
}
async keyEvent(keyCode) {
return this.shell(`input keyevent ${keyCode}`);
}
// Screen methods
async screenshot(path) {
return this.execute(`exec-out screencap -p > "${path}"`);
}
async getScreenSize() {
const output = await this.shell('wm size');
const match = output.match(/(\d+)x(\d+)/);
if (match) {
return {
width: parseInt(match[1]),
height: parseInt(match[2])
};
}
throw new Error('Could not determine screen size');
}
async getUIHierarchy() {
try {
console.log('🔍 Fetching UI hierarchy...');
// Try multiple approaches for UI dump, similar to your working implementation
const methods = [
// Method 1: Standard uiautomator dump
{
name: 'Standard UI Dump',
action: async () => {
await this.shell('uiautomator dump /sdcard/ui.xml');
return await this.shell('cat /sdcard/ui.xml');
}
},
// Method 2: Compressed dump
{
name: 'Compressed UI Dump',
action: async () => {
await this.shell('uiautomator dump --compressed /sdcard/ui.xml');
return await this.shell('cat /sdcard/ui.xml');
}
},
// Method 3: Direct to stdout
{
name: 'Direct stdout dump',
action: async () => {
return await this.shell('uiautomator dump /dev/tty');
}
},
// Method 4: Alternative location
{
name: 'Alternative location dump',
action: async () => {
await this.shell('uiautomator dump /data/local/tmp/ui.xml');
return await this.shell('cat /data/local/tmp/ui.xml');
}
}
];
for (const method of methods) {
try {
console.log(`📱 Trying ${method.name}...`);
const xmlContent = await method.action();
if (xmlContent && xmlContent.includes('<hierarchy')) {
console.log(`✅ UI hierarchy obtained using ${method.name}`);
console.log(`📊 XML length: ${xmlContent.length} characters`);
// Log first few attributes to verify we're getting resource IDs
const resourceIdMatch = xmlContent.match(/resource-id="[^"]+"/);
const textMatch = xmlContent.match(/text="[^"]+"/);
if (resourceIdMatch) {
console.log(`🎯 Found resource IDs: ${resourceIdMatch[0]}`);
}
if (textMatch) {
console.log(`📝 Found text: ${textMatch[0]}`);
}
return xmlContent;
}
} catch (error) {
console.warn(`❌ ${method.name} failed: ${error.message}`);
continue;
}
}
throw new Error('All UI dump methods failed');
} catch (error) {
console.error('❌ UI hierarchy retrieval failed:', error.message);
// Return a minimal XML structure instead of throwing
return `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" text="UI Dump Failed - Please refresh" resource-id="" class="android.widget.FrameLayout"
bounds="[0,0][1080,1920]" content-desc="UI dump failed" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" visible-to-user="true" package="system"/>
</hierarchy>`;
}
}
// Utility methods
async back() {
return this.keyEvent(4); // KEYCODE_BACK
}
async home() {
return this.keyEvent(3); // KEYCODE_HOME
}
async menu() {
return this.keyEvent(82); // KEYCODE_MENU
}
async recentApps() {
return this.keyEvent(187); // KEYCODE_APP_SWITCH
}
}