droideer
Version:
The Puppeteer for Android - Control Android devices with familiar web automation syntax
451 lines (386 loc) • 14.8 kB
JavaScript
export class AndroidElement {
constructor(device, data) {
this.device = device;
this._data = data;
// Core properties with proper fallbacks
this.id = data.id || 0;
this.tag = data.tag || 'node';
this.className = data.class || data.tag || 'android.view.View';
this.resourceId = data['resource-id'] || '';
this.text = data.text || '';
this.contentDesc = data['content-desc'] || '';
this.bounds = data.bounds || '[0,0][0,0]';
this.index = data.index || '0';
this.package = data.package || '';
// Boolean properties
this.isClickable = data.clickable === 'true';
this.isLongClickable = data['long-clickable'] === 'true';
this.isEnabled = data.enabled !== 'false'; // Default to true
this.isSelected = data.selected === 'true';
this.isFocused = data.focused === 'true';
this.isFocusable = data.focusable === 'true';
this.isScrollable = data.scrollable === 'true';
this.isCheckable = data.checkable === 'true';
this.isChecked = data.checked === 'true';
this.isPassword = data.password === 'true';
this.isVisibleToUser = data['visible-to-user'] !== 'false'; // Default to true
// Parse bounds for easy access
this.boundsRect = this._parseBounds(this.bounds);
// Store all original data for debugging
this.attributes = data.attributes || data;
// Generate selector
this.selector = data.selector || this._generateSelector();
}
_parseBounds(bounds) {
if (!bounds) return null;
const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (match) {
const [, x1, y1, x2, y2] = match.map(Number);
return {
x1, y1, x2, y2,
centerX: Math.floor((x1 + x2) / 2),
centerY: Math.floor((y1 + y2) / 2),
width: x2 - x1,
height: y2 - y1
};
}
return null;
}
_generateSelector() {
let selector = this.className;
if (this.resourceId) {
selector += `[@resource-id="${this.resourceId}"]`;
} else if (this.text && this.text.length > 0) {
const escapedText = this.text.replace(/"/g, '\\"');
selector += `[@text="${escapedText}"]`;
} else if (this.contentDesc && this.contentDesc.length > 0) {
const escapedDesc = this.contentDesc.replace(/"/g, '\\"');
selector += `[@content-desc="${escapedDesc}"]`;
}
if (this.index && this.index !== '0') {
selector += `[${this.index}]`;
}
return selector;
}
// Action methods
async click() {
if (!this.isClickable) {
console.warn(`Element is not clickable: ${this.selector}`);
}
if (!this.boundsRect) {
throw new Error('Cannot click element without valid bounds');
}
await this.device.adb.tap(this.boundsRect.centerX, this.boundsRect.centerY);
await this.device.waitForIdle();
return this;
}
async doubleClick() {
if (!this.boundsRect) {
throw new Error('Cannot double click element without valid bounds');
}
await this.device.adb.tap(this.boundsRect.centerX, this.boundsRect.centerY);
await this.device.wait(100);
await this.device.adb.tap(this.boundsRect.centerX, this.boundsRect.centerY);
await this.device.waitForIdle();
return this;
}
async longPress(duration = 1000) {
if (!this.isLongClickable) {
console.warn(`Element is not long-clickable: ${this.selector}`);
}
if (!this.boundsRect) {
throw new Error('Cannot long press element without valid bounds');
}
await this.device.adb.swipe(
this.boundsRect.centerX,
this.boundsRect.centerY,
this.boundsRect.centerX,
this.boundsRect.centerY,
duration
);
await this.device.waitForIdle();
return this;
}
async type(text, options = {}) {
// First click the element to focus it
await this.click();
await this.device.wait(100);
// Clear existing text if requested
if (options.clear !== false) {
await this.clear();
}
// Type the text
await this.device.adb.type(text);
await this.device.waitForIdle();
return this;
}
async clear() {
await this.click();
await this.device.wait(100);
// Select all text and delete
await this.device.adb.keyEvent(122); // CTRL+A equivalent
await this.device.wait(50);
await this.device.adb.keyEvent(67); // DEL key
await this.device.waitForIdle();
return this;
}
async focus() {
await this.click();
return this;
}
async scrollIntoView() {
// If element is not visible, try scrolling to find it
if (!this.isVisible()) {
// Try scrolling down first
for (let i = 0; i < 5; i++) {
await this.device.page.scroll('down');
await this.device.wait(500);
// Check if element is now visible (would need to refresh element data)
// This is a simplified version - in practice, you'd need to re-query the element
break;
}
}
return this;
}
// State checking methods
async isVisible() {
if (!this.isVisibleToUser) return false;
if (!this.boundsRect) return false;
// Check if element has non-zero dimensions
return this.boundsRect.width > 0 && this.boundsRect.height > 0;
}
async isDisplayed() {
return this.isVisible();
}
async isEnabled() {
return this.isEnabled;
}
async isSelected() {
return this.isSelected;
}
async getAttribute(name) {
// Map common attribute names to our properties
const attributeMap = {
'class': this.className,
'className': this.className,
'resource-id': this.resourceId,
'resourceId': this.resourceId,
'text': this.text,
'content-desc': this.contentDesc,
'contentDesc': this.contentDesc,
'bounds': this.bounds,
'clickable': this.isClickable.toString(),
'enabled': this.isEnabled.toString(),
'selected': this.isSelected.toString(),
'focused': this.isFocused.toString(),
'focusable': this.isFocusable.toString(),
'scrollable': this.isScrollable.toString(),
'checkable': this.isCheckable.toString(),
'checked': this.isChecked.toString(),
'long-clickable': this.isLongClickable.toString(),
'password': this.isPassword.toString(),
'visible-to-user': this.isVisibleToUser.toString(),
'package': this.package,
'index': this.index
};
return attributeMap[name] || this.attributes[name] || null;
}
async getText() {
return this.text;
}
async getContentDesc() {
return this.contentDesc;
}
async getResourceId() {
return this.resourceId;
}
async getClassName() {
return this.className;
}
async getBounds() {
return this.boundsRect;
}
async getLocation() {
return this.boundsRect ? {
x: this.boundsRect.centerX,
y: this.boundsRect.centerY
} : null;
}
async getSize() {
return this.boundsRect ? {
width: this.boundsRect.width,
height: this.boundsRect.height
} : null;
}
async getRect() {
return this.boundsRect;
}
// Utility methods
toString() {
const parts = [this.className];
if (this.resourceId) {
parts.push(`[@resource-id="${this.resourceId}"]`);
}
if (this.text) {
parts.push(`[@text="${this.text.substring(0, 20)}${this.text.length > 20 ? '...' : ''}"]`);
}
if (this.contentDesc) {
parts.push(`[@content-desc="${this.contentDesc.substring(0, 20)}${this.contentDesc.length > 20 ? '...' : ''}"]`);
}
return parts.join('');
}
// For debugging
toJSON() {
return {
id: this.id,
tag: this.tag,
className: this.className,
resourceId: this.resourceId,
text: this.text,
contentDesc: this.contentDesc,
bounds: this.bounds,
boundsRect: this.boundsRect,
index: this.index,
package: this.package,
isClickable: this.isClickable,
isEnabled: this.isEnabled,
isVisible: this.isVisibleToUser,
selector: this.selector,
attributes: this.attributes
};
}
// Advanced interaction methods
async swipeLeft(distance) {
if (!this.boundsRect) {
throw new Error('Cannot swipe element without valid bounds');
}
const startX = this.boundsRect.centerX;
const startY = this.boundsRect.centerY;
const endX = Math.max(0, startX - (distance || this.boundsRect.width * 0.8));
await this.device.adb.swipe(startX, startY, endX, startY, 300);
await this.device.waitForIdle();
return this;
}
async swipeRight(distance) {
if (!this.boundsRect) {
throw new Error('Cannot swipe element without valid bounds');
}
const screenSize = await this.device.getScreenSize();
const startX = this.boundsRect.centerX;
const startY = this.boundsRect.centerY;
const endX = Math.min(screenSize.width, startX + (distance || this.boundsRect.width * 0.8));
await this.device.adb.swipe(startX, startY, endX, startY, 300);
await this.device.waitForIdle();
return this;
}
async swipeUp(distance) {
if (!this.boundsRect) {
throw new Error('Cannot swipe element without valid bounds');
}
const startX = this.boundsRect.centerX;
const startY = this.boundsRect.centerY;
const endY = Math.max(0, startY - (distance || this.boundsRect.height * 0.8));
await this.device.adb.swipe(startX, startY, startX, endY, 300);
await this.device.waitForIdle();
return this;
}
async swipeDown(distance) {
if (!this.boundsRect) {
throw new Error('Cannot swipe element without valid bounds');
}
const screenSize = await this.device.getScreenSize();
const startX = this.boundsRect.centerX;
const startY = this.boundsRect.centerY;
const endY = Math.min(screenSize.height, startY + (distance || this.boundsRect.height * 0.8));
await this.device.adb.swipe(startX, startY, startX, endY, 300);
await this.device.waitForIdle();
return this;
}
// Scroll methods for scrollable elements
async scrollToTop() {
if (!this.isScrollable) {
console.warn(`Element is not scrollable: ${this.selector}`);
return this;
}
// Perform multiple upward swipes
for (let i = 0; i < 10; i++) {
await this.swipeUp();
await this.device.wait(200);
}
return this;
}
async scrollToBottom() {
if (!this.isScrollable) {
console.warn(`Element is not scrollable: ${this.selector}`);
return this;
}
// Perform multiple downward swipes
for (let i = 0; i < 10; i++) {
await this.swipeDown();
await this.device.wait(200);
}
return this;
}
// Check if element matches a selector
matches(selector) {
if (typeof selector === 'string') {
// Simple string matching
if (selector.startsWith('#')) {
const id = selector.substring(1);
return this.resourceId === id || this.resourceId.endsWith(`:id/${id}`);
}
if (selector.startsWith('.')) {
const className = selector.substring(1);
return this.className.includes(className);
}
return this.text === selector || this.contentDesc === selector;
}
if (typeof selector === 'object') {
// Object selector matching
for (const [key, value] of Object.entries(selector)) {
switch (key) {
case 'resourceId':
case 'id':
if (this.resourceId !== value && !this.resourceId.endsWith(`:id/${value}`)) {
return false;
}
break;
case 'text':
if (value instanceof RegExp) {
if (!value.test(this.text)) return false;
} else if (this.text !== value) return false;
break;
case 'contains':
case 'textContains':
if (!this.text.toLowerCase().includes(value.toLowerCase()) &&
!this.contentDesc.toLowerCase().includes(value.toLowerCase())) {
return false;
}
break;
case 'className':
case 'class':
if (value instanceof RegExp) {
if (!value.test(this.className)) return false;
} else if (this.className !== value) return false;
break;
case 'contentDesc':
case 'description':
if (value instanceof RegExp) {
if (!value.test(this.contentDesc)) return false;
} else if (this.contentDesc !== value) return false;
break;
case 'clickable':
if (this.isClickable !== Boolean(value)) return false;
break;
case 'enabled':
if (this.isEnabled !== Boolean(value)) return false;
break;
default:
if (this.attributes[key] !== value) return false;
}
}
return true;
}
return false;
}
}