@phone-use/sdk 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +345 -136
- package/dist/index.mjs +934 -232
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/backends/android-hierarchy.ts +153 -0
- package/src/backends/android.ts +890 -0
- package/src/backends/cloud-sandbox.ts +42 -1
- package/src/index.ts +17 -2
- package/src/lifecycle.ts +3 -3
package/package.json
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Parse an Android `uiautomator dump` XML tree into phone-use SnapshotNodes.
|
|
2
|
+
//
|
|
3
|
+
// The dump is a flat-ish XML of <node> elements, each carrying the attributes
|
|
4
|
+
// Android exposes for accessibility: text, content-desc, resource-id, class,
|
|
5
|
+
// bounds, and the interactive flags (clickable, enabled, focused, …). This is
|
|
6
|
+
// the Android analogue of the iOS accessibility tree the other backends read.
|
|
7
|
+
|
|
8
|
+
import type { Rect, SnapshotNode } from '../device.ts';
|
|
9
|
+
|
|
10
|
+
/** `[x1,y1][x2,y2]` → a top-left Rect. Returns undefined if unparseable. */
|
|
11
|
+
export function parseBounds(bounds: string | undefined): Rect | undefined {
|
|
12
|
+
const m = bounds?.match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/);
|
|
13
|
+
if (!m) return undefined;
|
|
14
|
+
const x1 = Number(m[1]);
|
|
15
|
+
const y1 = Number(m[2]);
|
|
16
|
+
const x2 = Number(m[3]);
|
|
17
|
+
const y2 = Number(m[4]);
|
|
18
|
+
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `android.widget.Button` → `Button`; keeps a bare token as-is. */
|
|
22
|
+
function shortType(className: string | undefined): string | undefined {
|
|
23
|
+
if (!className) return undefined;
|
|
24
|
+
const tail = className.split('.').pop();
|
|
25
|
+
return tail && tail.length > 0 ? tail : className;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalise Android widget classes to the harness's canonical (iOS-named)
|
|
30
|
+
* element types, so every layer above — inputFields(), setField(), the
|
|
31
|
+
* interactive/editable sets in the SDK — works unchanged on Android.
|
|
32
|
+
*
|
|
33
|
+
* This is load-bearing: without it `type --field` reports "no editable text
|
|
34
|
+
* field" on a real Android because `AutoCompleteTextView` never matches the
|
|
35
|
+
* SDK's EDITABLE set ({SearchField, TextField, SecureTextField}). Measured on
|
|
36
|
+
* a OnePlus Nord: the Settings search box is an AutoCompleteTextView.
|
|
37
|
+
*/
|
|
38
|
+
export function canonicalType(
|
|
39
|
+
shortName: string | undefined,
|
|
40
|
+
attrs: Record<string, string>,
|
|
41
|
+
): string | undefined {
|
|
42
|
+
if (!shortName) return undefined;
|
|
43
|
+
const isPassword = attrs.password === 'true';
|
|
44
|
+
switch (shortName) {
|
|
45
|
+
case 'EditText':
|
|
46
|
+
case 'AutoCompleteTextView':
|
|
47
|
+
case 'MultiAutoCompleteTextView':
|
|
48
|
+
case 'SearchView':
|
|
49
|
+
case 'TextInputEditText':
|
|
50
|
+
return isPassword ? 'SecureTextField' : 'TextField';
|
|
51
|
+
case 'Switch':
|
|
52
|
+
case 'SwitchCompat':
|
|
53
|
+
case 'CheckBox':
|
|
54
|
+
case 'ToggleButton':
|
|
55
|
+
case 'RadioButton':
|
|
56
|
+
return 'Switch';
|
|
57
|
+
case 'SeekBar':
|
|
58
|
+
return 'Slider';
|
|
59
|
+
case 'ImageButton':
|
|
60
|
+
return 'Button';
|
|
61
|
+
default:
|
|
62
|
+
// Any clickable container is a tap target regardless of its widget
|
|
63
|
+
// class. DeskClock's tab bar is clickable FrameLayouts with content-desc
|
|
64
|
+
// ('Stopwatch', 'Timer'…): they rendered in observe but FrameLayout is
|
|
65
|
+
// not a TAPPABLE role, so `tap "Stopwatch"` failed 16 straight runs on a
|
|
66
|
+
// control that was on screen. Clickable → Button makes every such
|
|
67
|
+
// control targetable by label.
|
|
68
|
+
if (attrs.clickable === 'true') return 'Button';
|
|
69
|
+
return shortName;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ATTR = /([\w:-]+)="([^"]*)"/g;
|
|
74
|
+
|
|
75
|
+
function attrs(fragment: string): Record<string, string> {
|
|
76
|
+
const out: Record<string, string> = {};
|
|
77
|
+
for (const m of fragment.matchAll(ATTR)) {
|
|
78
|
+
const key = m[1];
|
|
79
|
+
const val = m[2];
|
|
80
|
+
if (key !== undefined && val !== undefined) out[key] = decodeEntities(val);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function decodeEntities(s: string): string {
|
|
86
|
+
return s
|
|
87
|
+
.replaceAll('&', '&')
|
|
88
|
+
.replaceAll('<', '<')
|
|
89
|
+
.replaceAll('>', '>')
|
|
90
|
+
.replaceAll('"', '"')
|
|
91
|
+
.replaceAll(''', "'");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const isTrue = (v: string | undefined): boolean => v === 'true';
|
|
95
|
+
|
|
96
|
+
export type ParsedHierarchy = {
|
|
97
|
+
nodes: SnapshotNode[];
|
|
98
|
+
/** ref → rect, so a later press-by-ref can tap the element's centre. */
|
|
99
|
+
rects: Map<string, Rect>;
|
|
100
|
+
/** The package the tree belongs to (frontmost app), if the dump carries one. */
|
|
101
|
+
packageName?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse a uiautomator XML dump. Each <node> becomes a SnapshotNode with a
|
|
106
|
+
* fresh `@aN` ref. When `interactiveOnly`, keep only nodes that are actually
|
|
107
|
+
* actionable (clickable / long-clickable / a focusable field) or that carry a
|
|
108
|
+
* label — the rest is layout scaffolding an agent should not try to tap.
|
|
109
|
+
*/
|
|
110
|
+
export function parseHierarchy(xml: string, opts: { interactiveOnly?: boolean } = {}): ParsedHierarchy {
|
|
111
|
+
const nodes: SnapshotNode[] = [];
|
|
112
|
+
const rects = new Map<string, Rect>();
|
|
113
|
+
let packageName: string | undefined;
|
|
114
|
+
let index = 0;
|
|
115
|
+
|
|
116
|
+
// Match both self-closing <node ... /> and opening <node ...> tags; the tree
|
|
117
|
+
// is walked flat, which is enough — geometry lives in bounds, not nesting.
|
|
118
|
+
for (const m of xml.matchAll(/<node\b([^>]*?)\/?>/g)) {
|
|
119
|
+
const fragment = m[1];
|
|
120
|
+
if (fragment === undefined) continue;
|
|
121
|
+
const a = attrs(fragment);
|
|
122
|
+
if (!packageName && a.package) packageName = a.package;
|
|
123
|
+
|
|
124
|
+
const label = a.text || a['content-desc'] || undefined;
|
|
125
|
+
const clickable = isTrue(a.clickable) || isTrue(a['long-clickable']);
|
|
126
|
+
const focusableField = isTrue(a.focusable) && (label !== undefined || isTrue(a.editable));
|
|
127
|
+
|
|
128
|
+
if (opts.interactiveOnly && !clickable && !focusableField && !label) continue;
|
|
129
|
+
|
|
130
|
+
index += 1;
|
|
131
|
+
const ref = `@a${index}`;
|
|
132
|
+
const rect = parseBounds(a.bounds);
|
|
133
|
+
if (rect) rects.set(ref, rect);
|
|
134
|
+
|
|
135
|
+
const node: SnapshotNode = {
|
|
136
|
+
ref,
|
|
137
|
+
type: canonicalType(shortType(a.class), a),
|
|
138
|
+
role: canonicalType(shortType(a.class), a),
|
|
139
|
+
...(label !== undefined ? { label } : {}),
|
|
140
|
+
...(a.text && a.text !== label ? { value: a.text } : {}),
|
|
141
|
+
...(a['resource-id'] ? { identifier: a['resource-id'] } : {}),
|
|
142
|
+
enabled: a.enabled ? isTrue(a.enabled) : true,
|
|
143
|
+
selected: isTrue(a.selected),
|
|
144
|
+
focused: isTrue(a.focused),
|
|
145
|
+
...(rect ? { rect } : {}),
|
|
146
|
+
// A disabled control can't be actioned; surface why, mirroring iOS.
|
|
147
|
+
...(a.enabled && !isTrue(a.enabled) ? { interactionBlocked: 'disabled' } : {}),
|
|
148
|
+
};
|
|
149
|
+
nodes.push(node);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { nodes, rects, ...(packageName ? { packageName } : {}) };
|
|
153
|
+
}
|