@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phone-use/sdk",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Typed mobile-device SDK — Device, backends, errors, actions",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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('&amp;', '&')
88
+ .replaceAll('&lt;', '<')
89
+ .replaceAll('&gt;', '>')
90
+ .replaceAll('&quot;', '"')
91
+ .replaceAll('&apos;', "'");
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
+ }