Skip to content

Commit 600e956

Browse files
authored
refactor: share android hierarchy metadata (#497)
* refactor: share android hierarchy metadata * refactor: drop unused android bounds helper
1 parent 5df37ec commit 600e956

3 files changed

Lines changed: 69 additions & 76 deletions

File tree

src/platforms/android/__tests__/index.test.ts

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
import { withAndroidAdbProvider } from '../adb-executor.ts';
2929
import type { DeviceInfo } from '../../../utils/device.ts';
3030
import { AppError } from '../../../utils/errors.ts';
31-
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
31+
import { androidUiNodes, parseUiHierarchy } from '../ui-hierarchy.ts';
3232

3333
async function withMockedAdb(
3434
tempPrefix: string,
@@ -116,17 +116,26 @@ test('parseUiHierarchy decodes XML entities in Android node attributes', () => {
116116
assert.equal(result.nodes[0].label, 'Line 1\nLine 2\t&<>"\'');
117117
});
118118

119-
test('findBounds supports single and double quoted attributes', () => {
120-
const xml = [
121-
'<hierarchy>',
122-
'<node text="Nothing" content-desc="Irrelevant" bounds="[0,0][10,10]"/>',
123-
"<node text='Target from single quote' content-desc='Alt single' bounds='[100,200][300,500]'/>",
124-
'<node text="Target from double quote" content-desc="Alt double" bounds="[50,50][150,250]"/>',
125-
'</hierarchy>',
126-
].join('');
127-
128-
assert.deepEqual(findBounds(xml, 'single quote'), { x: 200, y: 350 });
129-
assert.deepEqual(findBounds(xml, 'alt double'), { x: 100, y: 150 });
119+
test('androidUiNodes exposes decoded Android hierarchy metadata', () => {
120+
const xml =
121+
'<hierarchy><node package="com.example.app" class="android.widget.EditText" text="Fish &amp; Chips" content-desc="Search&#10;field" resource-id="com.example.app:id/search" bounds="[10,20][110,70]" clickable="false" enabled="true" focusable="true" focused="true" password="true"/></hierarchy>';
122+
123+
assert.deepEqual(Array.from(androidUiNodes(xml)), [
124+
{
125+
text: 'Fish & Chips',
126+
desc: 'Search\nfield',
127+
resourceId: 'com.example.app:id/search',
128+
packageName: 'com.example.app',
129+
className: 'android.widget.EditText',
130+
bounds: '[10,20][110,70]',
131+
rect: { x: 10, y: 20, width: 100, height: 50 },
132+
clickable: false,
133+
enabled: true,
134+
focusable: true,
135+
focused: true,
136+
password: true,
137+
},
138+
]);
130139
});
131140

132141
test('parseUiHierarchy ignores attribute-name prefix spoofing', () => {
@@ -138,16 +147,6 @@ test('parseUiHierarchy ignores attribute-name prefix spoofing', () => {
138147
assert.equal(result.nodes[0].value, 'Actual');
139148
});
140149

141-
test('findBounds ignores bounds-like fragments inside other attribute values', () => {
142-
const xml = [
143-
'<hierarchy>',
144-
"<node text='Target' content-desc=\"metadata bounds='[900,900][1000,1000]'\" bounds='[100,200][300,500]'/>",
145-
'</hierarchy>',
146-
].join('');
147-
148-
assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
149-
});
150-
151150
test('scrollAndroid supports explicit pixel travel distance', async () => {
152151
await withMockedAdb(
153152
'agent-device-android-scroll-pixels-',

src/platforms/android/fill-verification.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '../fill-diagnostics.ts';
1010
import { sleep } from './adb.ts';
1111
import { dumpUiHierarchy } from './snapshot.ts';
12-
import { parseBounds, readNodeAttributes } from './ui-hierarchy.ts';
12+
import { androidUiNodes, type AndroidUiNodeMetadata } from './ui-hierarchy.ts';
1313

1414
export type AndroidFillVerificationNode = FillDiagnosticNode & {
1515
className: string | null;
@@ -145,16 +145,14 @@ function inspectAndroidTextAtPointInHierarchy(
145145
x: number,
146146
y: number,
147147
): AndroidTextAtPointInspection {
148-
const nodeRegex = /<node\b[^>]*>/g;
149-
let match: RegExpExecArray | null;
150148
const scan: AndroidTextAtPointScan = {
151149
focusedEdit: null,
152150
editAtPoint: null,
153151
anyAtPoint: null,
154152
};
155153

156-
while ((match = nodeRegex.exec(xml)) !== null) {
157-
const candidate = androidFillCandidateFromNode(match[0]);
154+
for (const node of androidUiNodes(xml)) {
155+
const candidate = androidFillCandidateFromNode(node);
158156
if (candidate) updateAndroidTextAtPointScan(scan, candidate, x, y);
159157
}
160158

@@ -228,23 +226,23 @@ function normalizeFillVerificationText(value: string | null): string {
228226
return (value ?? '').replace(/\s+/g, ' ').trim();
229227
}
230228

231-
function androidFillCandidateFromNode(node: string): AndroidFillVerificationCandidate | null {
232-
const attrs = readNodeAttributes(node);
233-
const rect = parseBounds(attrs.bounds);
234-
if (!rect) return null;
235-
const text = attrs.text ?? '';
236-
const area = Math.max(1, rect.width * rect.height);
229+
function androidFillCandidateFromNode(
230+
node: AndroidUiNodeMetadata,
231+
): AndroidFillVerificationCandidate | null {
232+
if (!node.rect) return null;
233+
const text = node.text ?? '';
234+
const area = Math.max(1, node.rect.width * node.rect.height);
237235
return {
238236
text: text || null,
239-
className: attrs.className,
240-
resourceId: attrs.resourceId,
241-
packageName: attrs.packageName,
242-
rect,
243-
focused: attrs.focused ?? false,
244-
password: attrs.password === true,
245-
inputMethodOwned: isInputMethodOwnedAndroidNode(attrs.packageName, attrs.resourceId),
237+
className: node.className,
238+
resourceId: node.resourceId,
239+
packageName: node.packageName,
240+
rect: node.rect,
241+
focused: node.focused ?? false,
242+
password: node.password === true,
243+
inputMethodOwned: isInputMethodOwnedAndroidNode(node.packageName, node.resourceId),
246244
area,
247-
editText: isEditTextClass(attrs.className ?? ''),
245+
editText: isEditTextClass(node.className ?? ''),
248246
};
249247
}
250248

src/platforms/android/ui-hierarchy.ts

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,37 @@ export type AndroidSnapshotAnalysis = {
66
maxDepth: number;
77
};
88

9-
export function findBounds(xml: string, query: string): { x: number; y: number } | null {
10-
const q = query.toLowerCase();
11-
const nodeRegex = /<node[^>]+>/g;
9+
export type AndroidUiNodeMetadata = {
10+
text: string | null;
11+
desc: string | null;
12+
resourceId: string | null;
13+
packageName: string | null;
14+
className: string | null;
15+
bounds: string | null;
16+
rect?: Rect;
17+
clickable?: boolean;
18+
enabled?: boolean;
19+
focusable?: boolean;
20+
focused?: boolean;
21+
password?: boolean;
22+
};
23+
24+
export function* androidUiNodes(xml: string): IterableIterator<AndroidUiNodeMetadata> {
25+
const nodeRegex = /<node\b[^>]*>/g;
1226
let match = nodeRegex.exec(xml);
1327
while (match) {
14-
const node = match[0];
15-
const attrs = parseXmlNodeAttributes(node);
16-
const textVal = (readXmlAttr(attrs, 'text') ?? '').toLowerCase();
17-
const descVal = (readXmlAttr(attrs, 'content-desc') ?? '').toLowerCase();
18-
if (textVal.includes(q) || descVal.includes(q)) {
19-
const rect = parseBounds(readXmlAttr(attrs, 'bounds'));
20-
if (rect) {
21-
return {
22-
x: Math.floor(rect.x + rect.width / 2),
23-
y: Math.floor(rect.y + rect.height / 2),
24-
};
25-
}
26-
return { x: 0, y: 0 };
27-
}
28+
yield readAndroidUiNodeMetadata(match[0]);
2829
match = nodeRegex.exec(xml);
2930
}
30-
return null;
31+
}
32+
33+
function readAndroidUiNodeMetadata(node: string): AndroidUiNodeMetadata {
34+
const attrs = readNodeAttributes(node);
35+
const rect = parseBounds(attrs.bounds);
36+
return {
37+
...attrs,
38+
...(rect ? { rect } : {}),
39+
};
3140
}
3241

3342
export function parseUiHierarchy(
@@ -172,19 +181,7 @@ function hasInteractiveDescendant(state: AndroidSnapshotBuildState, node: Androi
172181
return false;
173182
}
174183

175-
export function readNodeAttributes(node: string): {
176-
text: string | null;
177-
desc: string | null;
178-
resourceId: string | null;
179-
packageName: string | null;
180-
className: string | null;
181-
bounds: string | null;
182-
clickable?: boolean;
183-
enabled?: boolean;
184-
focusable?: boolean;
185-
focused?: boolean;
186-
password?: boolean;
187-
} {
184+
function readNodeAttributes(node: string): Omit<AndroidUiNodeMetadata, 'rect'> {
188185
const attrs = parseXmlNodeAttributes(node);
189186
const getAttr = (name: string): string | null => readXmlAttr(attrs, name);
190187
const boolAttr = (name: string): boolean | undefined => {
@@ -332,7 +329,7 @@ function readXmlAttr(attrs: Map<string, string>, name: string): string | null {
332329
return attrs.get(name) ?? null;
333330
}
334331

335-
export function parseBounds(bounds: string | null): Rect | undefined {
332+
function parseBounds(bounds: string | null): Rect | undefined {
336333
if (!bounds) return undefined;
337334
const match = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds);
338335
if (!match) return undefined;
@@ -387,15 +384,14 @@ export function parseUiHierarchyTree(xml: string): AndroidUiHierarchy {
387384
match = tokenRegex.exec(xml);
388385
continue;
389386
}
390-
const attrs = readNodeAttributes(token);
391-
const rect = parseBounds(attrs.bounds);
387+
const attrs = readAndroidUiNodeMetadata(token);
392388
const parent = stack[stack.length - 1];
393389
const node: AndroidUiHierarchy = {
394390
type: attrs.className,
395391
label: attrs.text || attrs.desc,
396392
value: attrs.text,
397393
identifier: attrs.resourceId,
398-
rect,
394+
rect: attrs.rect,
399395
enabled: attrs.enabled,
400396
hittable: attrs.clickable ?? attrs.focusable,
401397
depth: parent.depth + 1,

0 commit comments

Comments
 (0)