@flxapps/adbax 0.0.0-stage → 0.1.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.
@@ -0,0 +1,112 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { execFileSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { match } from '../select.js';
6
+ import { kind, label, shortId } from '../tree.js';
7
+ import { cachePath, fail } from '../util.js';
8
+ import { selectorOptions, showUi } from './common.js';
9
+
10
+ export async function ui(ctx) {
11
+ await showUi(ctx);
12
+ }
13
+
14
+ export async function find(ctx) {
15
+ const q = ctx.pos.join(' ') || fail('usage: adbax find <text>');
16
+ const dev = await ctx.device();
17
+ const nodes = await dev.tree();
18
+ const hits = match(nodes, q, { regex: !!ctx.flags.re });
19
+ if (!hits.length) fail(`no match for ${JSON.stringify(q)} on ${await dev.where()}`, 2);
20
+ const rows = dev.saveRows(nodes, !!ctx.flags.all);
21
+ for (const n of hits) {
22
+ const idx = rows.indexOf(n);
23
+ const id = shortId(n);
24
+ ctx.out(`${idx > -1 ? `[${idx}]` : '[-]'} ${kind(n).padEnd(6)} ${JSON.stringify(label(n))} ${id ? `id=${id} ` : ''}` +
25
+ `cls=${n.cls} @${n.cx},${n.cy} ${n.w}x${n.h}${n.enabled ? '' : ' !disabled'}`);
26
+ }
27
+ }
28
+
29
+ export async function inspect(ctx) {
30
+ const q = ctx.pos.join(' ') || fail('usage: adbax inspect <query>');
31
+ const dev = await ctx.device();
32
+ const nodes = await dev.tree();
33
+ const opts = selectorOptions(ctx.flags);
34
+ const hits = match(nodes, q, { regex: opts.regex });
35
+ const n = hits[opts.nth !== undefined ? +opts.nth : 0] || fail(`no node matching ${JSON.stringify(q)}`, 2);
36
+ ctx.out(JSON.stringify({
37
+ class: n.fullCls, text: n.text, 'content-desc': n.desc, hint: n.hint, 'resource-id': n.id, package: n.pkg,
38
+ bounds: `[${n.x1},${n.y1}][${n.x2},${n.y2}]`, center: [n.cx, n.cy], size: [n.w, n.h],
39
+ clickable: n.clickable, longClickable: n.longClickable, editable: n.editable, checkable: n.checkable,
40
+ checked: n.checked, scrollable: n.scrollable, enabled: n.enabled, focused: n.focused,
41
+ focusable: n.focusable, password: n.password,
42
+ }, null, 2));
43
+ const chain = [];
44
+ for (let a = n.parent; a; a = a.parent) {
45
+ chain.unshift(a.cls + (shortId(a) ? '#' + shortId(a) : '') + (a.clickable ? '(clickable)' : ''));
46
+ }
47
+ ctx.out('ancestors: ' + chain.join(' > '));
48
+ if (hits.length > 1) ctx.out(`(${hits.length} matches; --nth N picks another)`);
49
+ }
50
+
51
+ export async function current(ctx) {
52
+ const dev = await ctx.device();
53
+ ctx.out(await dev.where());
54
+ const a = (await dev.adb.shell("dumpsys activity activities 2>/dev/null | grep -m1 'ResumedActivity'", { soft: true })).trim();
55
+ if (a) ctx.out(a);
56
+ }
57
+
58
+ /** Resizes or converts on the host where the device could not; only macOS has a tool for it built in. */
59
+ function convertOnHost(file, { width, jpeg }) {
60
+ if (process.platform !== 'darwin') return false;
61
+ const args = [];
62
+ if (width) args.push('--resampleWidth', String(width));
63
+ if (jpeg) args.push('-s', 'format', 'jpeg');
64
+ try {
65
+ execFileSync('sips', [...args, file, '--out', file], { stdio: 'ignore' });
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Writes a screenshot. Prints the path on stdout and the image size on stderr;
74
+ * `tap x,y --shot` later takes coordinates in this image's pixels.
75
+ */
76
+ export async function screenshot(ctx) {
77
+ const { flags } = ctx;
78
+ const dev = await ctx.device();
79
+ let dest = path.resolve(ctx.pos[0] || cachePath(flags.small || flags.grid ? 'shot.jpg' : 'shot.png'));
80
+ const width = +(flags.width || flags.scale || 0) || (flags.small || flags.grid ? 540 : 0);
81
+ const grid = flags.grid ? (flags.grid === true ? Math.round((width || 540) / 6) : +flags.grid) : 0;
82
+ const jpeg = /\.jpe?g$/i.test(dest);
83
+ const shot = await dev.screenshot({ width, grid, format: jpeg ? 'jpeg' : 'png', quality: +(flags.quality || 80) });
84
+ fs.writeFileSync(dest, shot.data);
85
+ if (grid && !shot.scaled) process.stderr.write('adbax: --grid needs the on-device server; saved without it\n');
86
+ const device = shot.screenWidth ? { width: shot.screenWidth, height: shot.screenHeight } : await dev.screenSize();
87
+ let w = shot.width || (width || device.width);
88
+ if (!shot.scaled && (width || jpeg) && !convertOnHost(dest, { width, jpeg })) {
89
+ if (jpeg) {
90
+ const png = dest.replace(/\.jpe?g$/i, '.png');
91
+ fs.renameSync(dest, png);
92
+ dest = png;
93
+ }
94
+ w = device.width;
95
+ process.stderr.write('adbax: no on-device server and no host image tool; kept the full-size PNG\n');
96
+ }
97
+ const h = shot.height || Math.round(device.height * w / device.width);
98
+ dev.saveShot({ width: w, height: h, deviceWidth: device.width, deviceHeight: device.height });
99
+ ctx.out(dest);
100
+ if (w !== device.width) process.stderr.write(`${w}x${h} of ${device.width}x${device.height}: tap x,y --shot takes these pixels\n`);
101
+ }
102
+
103
+ export async function record(ctx) {
104
+ const dev = await ctx.device();
105
+ const secs = Math.min(+(ctx.pos[0] || 15), 180);
106
+ const dest = path.resolve(ctx.pos[1] || cachePath('rec.mp4'));
107
+ process.stderr.write(`recording ${secs}s ...\n`);
108
+ await dev.adb.shell(`screenrecord --time-limit ${secs} --bit-rate 4000000 /sdcard/adbax-rec.mp4`, { timeout: (secs + 30) * 1000 });
109
+ await dev.adb.run(['pull', '/sdcard/adbax-rec.mp4', dest], { soft: true });
110
+ await dev.adb.shell('rm -f /sdcard/adbax-rec.mp4', { soft: true });
111
+ ctx.out(dest);
112
+ }
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { idleSeconds } from '../backends/server.js';
3
+ import { fail } from '../util.js';
4
+
5
+ /** adbax server [status|start|stop|restart|log] */
6
+ export async function server(ctx) {
7
+ const sub = ctx.pos[0] || 'status';
8
+ const dev = await ctx.device();
9
+ const s = dev.server;
10
+ switch (sub) {
11
+ case 'status': {
12
+ const st = await s.status();
13
+ if (!st.running) {
14
+ return ctx.out(st.pid ? `pid ${st.pid} is up but not answering on a forward; \`adbax server restart\`` : 'not running');
15
+ }
16
+ const p = st.ping;
17
+ return ctx.out(`running: pid ${p.pid}, sdk ${p.sdk}, up ${Math.round(p.uptimeMs / 1000)}s, port ${st.port}` +
18
+ (st.current ? '' : ', older build (replaced on next use)'));
19
+ }
20
+ case 'start':
21
+ case 'restart': {
22
+ if (sub === 'restart') await s.stop();
23
+ const head = await s.start({ idle: +(ctx.flags.idle || idleSeconds()), force: true });
24
+ return ctx.out(`started: pid ${head.pid}, sdk ${head.sdk}, exits after ${ctx.flags.idle || idleSeconds()}s idle`);
25
+ }
26
+ case 'stop':
27
+ await s.stop();
28
+ return ctx.out('stopped');
29
+ case 'log':
30
+ return ctx.out((await s.log()).trim() || '(no log)');
31
+ default:
32
+ fail('usage: adbax server [status|start|stop|restart|log] [--idle seconds]');
33
+ }
34
+ }
@@ -0,0 +1,102 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /* Logs, device settings for screenshots, and raw adb passthrough. */
3
+ import { fail, q } from '../util.js';
4
+
5
+ export async function log(ctx) {
6
+ const { flags } = ctx;
7
+ const pkg = ctx.pos[0];
8
+ const dev = await ctx.device();
9
+ const args = ['logcat', '-d', '-v', 'brief', '-t', String(flags.lines || 200)];
10
+ if (pkg) {
11
+ const pid = (await dev.adb.shell(`pidof ${q(pkg)}`, { soft: true })).trim().split(/\s+/)[0];
12
+ if (!pid) fail(`${pkg} is not running (no pid). Start it first, or drop the package argument.`);
13
+ args.push('--pid=' + pid);
14
+ }
15
+ if (flags.level) args.push('*:' + String(flags.level).toUpperCase());
16
+ let out = await dev.adb.run(args, { soft: true });
17
+ if (flags.grep) {
18
+ const re = new RegExp(String(flags.grep), 'i');
19
+ out = out.split('\n').filter((l) => re.test(l)).join('\n');
20
+ }
21
+ ctx.out(out.trim());
22
+ }
23
+
24
+ export async function crash(ctx) {
25
+ const dev = await ctx.device();
26
+ const out = (await dev.adb.run(['logcat', '-b', 'crash', '-d', '-t', String(ctx.flags.lines || 120)], { soft: true })).trim();
27
+ ctx.out(out || '(crash buffer empty)');
28
+ }
29
+
30
+ export async function clearlog(ctx) {
31
+ const dev = await ctx.device();
32
+ await dev.adb.run(['logcat', '-c'], { soft: true });
33
+ await dev.adb.run(['logcat', '-b', 'crash', '-c'], { soft: true });
34
+ ctx.out('log buffers cleared');
35
+ }
36
+
37
+ export async function dark(ctx) {
38
+ const on = ctx.pos[0] !== 'off';
39
+ await (await ctx.device()).adb.shell(`cmd uimode night ${on ? 'yes' : 'no'}`);
40
+ ctx.out('dark mode ' + (on ? 'on' : 'off'));
41
+ }
42
+
43
+ export async function font(ctx) {
44
+ const s = ctx.pos[0] || fail('usage: adbax font <0.85|1.0|1.3|2.0>');
45
+ if (!/^\d+(\.\d+)?$/.test(s)) fail('font: a scale like 1.0 or 1.3');
46
+ await (await ctx.device()).adb.shell(`settings put system font_scale ${s}`);
47
+ ctx.out('font_scale ' + s);
48
+ }
49
+
50
+ export async function anim(ctx) {
51
+ const on = ctx.pos[0] !== 'off';
52
+ const dev = await ctx.device();
53
+ const v = on ? '1.0' : '0.0';
54
+ await dev.adb.shell(['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']
55
+ .map((k) => `settings put global ${k} ${v}`).join('; '));
56
+ ctx.out('animations ' + (on ? 'on' : 'off'));
57
+ }
58
+
59
+ export async function rotate(ctx) {
60
+ const d = ctx.pos[0] || fail('usage: adbax rotate <0|90|180|270|auto>');
61
+ const dev = await ctx.device();
62
+ if (d === 'auto') {
63
+ await dev.adb.shell('settings put system accelerometer_rotation 1');
64
+ return ctx.out('rotation: auto');
65
+ }
66
+ const r = { 0: 0, 90: 1, 180: 2, 270: 3 }[d];
67
+ if (r === undefined) fail('rotate: 0, 90, 180, 270 or auto');
68
+ await dev.adb.shell(`settings put system accelerometer_rotation 0; settings put system user_rotation ${r}`);
69
+ ctx.out('rotation ' + d);
70
+ }
71
+
72
+ /** SystemUI demo mode: a clean status bar for store screenshots. */
73
+ export async function demo(ctx) {
74
+ const on = ctx.pos[0] !== 'off';
75
+ const dev = await ctx.device();
76
+ const b = (extras) => `am broadcast -a com.android.systemui.demo ${extras}`;
77
+ if (!on) {
78
+ await dev.adb.shell(b('-e command exit'), { soft: true });
79
+ return ctx.out('demo mode off');
80
+ }
81
+ await dev.adb.shell(['settings put global sysui_demo_allowed 1',
82
+ b('-e command enter'),
83
+ b('-e command clock -e hhmm 1000'),
84
+ b('-e command battery -e level 100 -e plugged false'),
85
+ b('-e command network -e wifi show -e level 4'),
86
+ b('-e command network -e mobile show -e datatype none -e level 4'),
87
+ b('-e command notifications -e visible false')].join(' >/dev/null; '), { soft: true });
88
+ ctx.out('demo mode on (clean status bar for store screenshots)');
89
+ }
90
+
91
+ export async function shell(ctx) {
92
+ if (!ctx.pos.length) fail('usage: adbax shell <command>');
93
+ ctx.out((await (await ctx.device()).adb.shell(ctx.pos.join(' '))).trim());
94
+ }
95
+
96
+ export async function pull(ctx) {
97
+ ctx.out((await (await ctx.device()).adb.run(['pull', ...ctx.pos])).trim());
98
+ }
99
+
100
+ export async function push(ctx) {
101
+ ctx.out((await (await ctx.device()).adb.run(['push', ...ctx.pos])).trim());
102
+ }
@@ -0,0 +1,210 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { describe, match, resolve, tappableAncestor } from '../select.js';
3
+ import { tappable } from '../tree.js';
4
+ import { delay, fail } from '../util.js';
5
+ import { KEYS, maybeUi, selectorOptions, selectorSource } from './common.js';
6
+
7
+ /** Screen pixels for x,y given with --shot, i.e. in the last screenshot's pixels. */
8
+ function shotPoint(dev, x, y) {
9
+ return dev.fromShot(x, y) || fail('--shot: no screenshot taken yet on this device');
10
+ }
11
+
12
+ async function target(ctx, usage) {
13
+ const sel = ctx.pos.join(' ') || fail(usage);
14
+ const dev = await ctx.device();
15
+ const r = await resolve(sel, selectorSource(dev), { forTap: true, ...selectorOptions(ctx.flags) });
16
+ if (ctx.flags.shot) {
17
+ if (r.how !== 'coords') fail('--shot only applies to x,y coordinates');
18
+ const [x, y] = shotPoint(dev, r.x, r.y);
19
+ return { dev, r: { x, y, how: `shot ${r.x},${r.y}` } };
20
+ }
21
+ return { dev, r };
22
+ }
23
+
24
+ export async function tap(ctx) {
25
+ const { dev, r } = await target(ctx, 'usage: adbax tap <text|index|x,y>');
26
+ await dev.tap(r.x, r.y);
27
+ ctx.out(`tapped ${r.how} @${r.x},${r.y}`);
28
+ await maybeUi(ctx);
29
+ }
30
+
31
+ export async function longpress(ctx) {
32
+ const { dev, r } = await target(ctx, 'usage: adbax longpress <text|index|x,y> [--dur ms]');
33
+ await dev.swipe(r.x, r.y, r.x, r.y, +(ctx.flags.dur || 900));
34
+ ctx.out(`long-pressed ${r.how} @${r.x},${r.y}`);
35
+ await maybeUi(ctx);
36
+ }
37
+
38
+ export async function type(ctx) {
39
+ const { flags } = ctx;
40
+ const text = ctx.pos.join(' ');
41
+ if (!text && !flags.clear && !flags.set) fail('usage: adbax type <text> [--into <field>] [--clear | --set] [--enter]');
42
+ const dev = await ctx.device();
43
+ let existing = 0;
44
+ if (flags.into) {
45
+ const r = await resolve(String(flags.into), selectorSource(dev), { forTap: true, ...selectorOptions(flags) });
46
+ existing = ((r.node && r.node.text) || '').length;
47
+ await dev.tap(r.x, r.y);
48
+ // Tapping a field often starts a new activity; typing before the field
49
+ // has focus loses the first characters into the outgoing window.
50
+ if (!(await dev.waitForInput(3000))) await delay(600);
51
+ ctx.out(`focused ${r.how}`);
52
+ }
53
+ if (flags.set) {
54
+ // The whole field in one ACTION_SET_TEXT: no keystrokes, nothing left over from what
55
+ // was there, and no IME in between. For long values, where typing over a selection
56
+ // races the field's own state.
57
+ const now = await dev.setText(text);
58
+ if (now === null) fail('adbax: no editable field has focus; tap it first or use --into');
59
+ ctx.out(`set ${JSON.stringify(text)}` + (now !== text ? ` field reads ${JSON.stringify(now)}` : ''));
60
+ return;
61
+ }
62
+ if (flags.clear) await dev.clear(existing);
63
+ let via = '';
64
+ if (text) {
65
+ if (!(await dev.fast()) && /[^\x00-\x7F]/.test(text)) {
66
+ process.stderr.write('adbax: `input text` drops non-ASCII on most builds; check the field.\n');
67
+ }
68
+ via = (await dev.type(text)).via;
69
+ }
70
+ if (flags.enter) await dev.key([66]);
71
+ ctx.out(`typed ${JSON.stringify(text)}` + (via === 'settext' ? ' (set as field text, not keystrokes)' : '') +
72
+ (flags.enter ? ' + enter' : ''));
73
+ if (text && !flags.enter && !flags['no-verify']) {
74
+ await delay((await dev.fast()) ? 100 : 400);
75
+ const f = await dev.focus();
76
+ if (f && f.focused && f.text && f.text !== text && !f.password) {
77
+ ctx.out(` field now reads ${JSON.stringify(f.text)}` + (f.text.length < text.length ? ' <- short, retry with --clear' : ''));
78
+ }
79
+ }
80
+ await maybeUi(ctx);
81
+ }
82
+
83
+ export async function key(ctx) {
84
+ if (!ctx.pos.length) fail(`usage: adbax key <name|number|KEYCODE_*>... (${Object.keys(KEYS).join(' ')})`);
85
+ const codes = ctx.pos.map((k) => {
86
+ const lk = k.toLowerCase();
87
+ if (KEYS[lk] !== undefined) return KEYS[lk];
88
+ if (/^\d+$/.test(k)) return +k;
89
+ if (/^KEYCODE_\w+$/i.test(k)) return k.toUpperCase();
90
+ return fail('unknown key: ' + k);
91
+ });
92
+ const dev = await ctx.device();
93
+ await dev.key(codes, { long: !!ctx.flags.long });
94
+ ctx.out('key ' + ctx.pos.join(' '));
95
+ await maybeUi(ctx);
96
+ }
97
+
98
+ function namedKey(code, name) {
99
+ return async (ctx) => {
100
+ await (await ctx.device()).key([code]);
101
+ ctx.out(name);
102
+ await maybeUi(ctx);
103
+ };
104
+ }
105
+ export const back = namedKey(4, 'back');
106
+
107
+ /**
108
+ * Closes the soft keyboard if one is showing, and does nothing otherwise. A bare
109
+ * `adbax back` closes the keyboard when it is up and leaves the screen when it is not,
110
+ * which turns a "hide the keyboard" step into a navigation bug.
111
+ */
112
+ export async function hideKeyboard(ctx) {
113
+ const dev = await ctx.device();
114
+ if (await dev.keyboardUp()) {
115
+ await dev.key([4]);
116
+ ctx.out('keyboard hidden');
117
+ } else {
118
+ ctx.out('no keyboard');
119
+ }
120
+ await maybeUi(ctx);
121
+ }
122
+ export const home = namedKey(3, 'home');
123
+ export const recents = namedKey(187, 'recents');
124
+
125
+ export async function swipe(ctx) {
126
+ const { flags } = ctx;
127
+ const a = ctx.pos[0] || fail('usage: adbax swipe <up|down|left|right|x1,y1,x2,y2> [--dur ms] [--in <sel>]');
128
+ const dur = +(flags.dur || 300);
129
+ const dev = await ctx.device();
130
+ let pts;
131
+ if (a.split(',').length === 4) {
132
+ pts = a.split(',').map((v) => +v.trim());
133
+ if (flags.shot) pts = [...shotPoint(dev, pts[0], pts[1]), ...shotPoint(dev, pts[2], pts[3])];
134
+ } else if (!['up', 'down', 'left', 'right'].includes(a)) {
135
+ fail(`swipe: ${a}? Use up, down, left, right or x1,y1,x2,y2.`);
136
+ } else if (flags.in) {
137
+ const n = match(await dev.tree(), String(flags.in), { regex: !!flags.re })[0] || fail(`no node for --in ${flags.in}`);
138
+ const mx = n.cx;
139
+ const my = n.cy;
140
+ const padY = Math.round(n.h * 0.2);
141
+ const padX = Math.round(n.w * 0.15);
142
+ pts = { up: [mx, n.y2 - padY, mx, n.y1 + padY], down: [mx, n.y1 + padY, mx, n.y2 - padY],
143
+ left: [n.x2 - padX, my, n.x1 + padX, my], right: [n.x1 + padX, my, n.x2 - padX, my] }[a];
144
+ } else {
145
+ const { width: W, height: H } = await dev.screenSize();
146
+ // "swipe up" is the finger going up, i.e. scrolling down the content.
147
+ const r = Math.round;
148
+ pts = { up: [r(W / 2), r(H * 0.75), r(W / 2), r(H * 0.28)], down: [r(W / 2), r(H * 0.28), r(W / 2), r(H * 0.75)],
149
+ left: [r(W * 0.85), r(H / 2), r(W * 0.15), r(H / 2)], right: [r(W * 0.15), r(H / 2), r(W * 0.85), r(H / 2)] }[a];
150
+ }
151
+ await dev.swipe(...pts, dur);
152
+ ctx.out(`swipe ${pts.join(' ')} ${dur}`);
153
+ await maybeUi(ctx);
154
+ }
155
+
156
+ export async function scrollTo(ctx) {
157
+ const { flags } = ctx;
158
+ const q = ctx.pos.join(' ') || fail('usage: adbax scroll-to <text> [--max 12] [--dir up|down] [--tap]');
159
+ const max = +(flags.max || 12);
160
+ const contentUp = flags.dir === 'up';
161
+ const dev = await ctx.device();
162
+ for (let i = 0; i <= max; i++) {
163
+ const nodes = await dev.tree({ afterAction: i > 0 });
164
+ const hits = match(nodes, q, { regex: !!flags.re });
165
+ if (hits.length) {
166
+ dev.saveRows(nodes);
167
+ const n = hits[0];
168
+ ctx.out(`found after ${i} swipe(s): ${describe(n)} @${n.cx},${n.cy}`);
169
+ if (flags.tap) {
170
+ const t = tappable(n) ? n : tappableAncestor(n);
171
+ await dev.tap(t.cx, t.cy);
172
+ ctx.out(`tapped it @${t.cx},${t.cy}`);
173
+ }
174
+ return maybeUi(ctx);
175
+ }
176
+ if (i === max) break;
177
+ const sc = nodes.filter((n) => n.scrollable && n.h > 200).sort((a, b) => b.h * b.w - a.h * a.w)[0];
178
+ let x, top, bottom;
179
+ if (sc) {
180
+ const pad = Math.round(sc.h * 0.2);
181
+ [x, top, bottom] = [sc.cx, sc.y1 + pad, sc.y2 - pad];
182
+ } else {
183
+ const { width: W, height: H } = await dev.screenSize();
184
+ [x, top, bottom] = [Math.round(W / 2), Math.round(H * 0.28), Math.round(H * 0.75)];
185
+ }
186
+ if (contentUp) await dev.swipe(x, top, x, bottom, 250);
187
+ else await dev.swipe(x, bottom, x, top, 250);
188
+ }
189
+ fail(`${JSON.stringify(q)} not found after ${max} swipes on ${await dev.where()}`, 2);
190
+ }
191
+
192
+ export async function wait(ctx) {
193
+ const { flags } = ctx;
194
+ const q = ctx.pos.join(' ') || fail('usage: adbax wait <text> [--timeout 15] [--gone]');
195
+ const dev = await ctx.device();
196
+ const deadline = Date.now() + +(flags.timeout || 15) * 1000;
197
+ const interval = await dev.pollInterval();
198
+ while (Date.now() < deadline) {
199
+ const nodes = await dev.tree();
200
+ const hits = match(nodes, q, { regex: !!flags.re });
201
+ if (flags.gone ? !hits.length : hits.length) {
202
+ dev.saveRows(nodes);
203
+ if (hits.length) ctx.out(`ready: ${describe(hits[0])} @${hits[0].cx},${hits[0].cy}`);
204
+ else ctx.out(`gone: ${JSON.stringify(q)}`);
205
+ return maybeUi(ctx);
206
+ }
207
+ await delay(interval);
208
+ }
209
+ fail(`timed out waiting for ${JSON.stringify(q)}${flags.gone ? ' to disappear' : ''} on ${await dev.where()}`, 2);
210
+ }
package/src/device.js ADDED
@@ -0,0 +1,197 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { Adb } from './adb.js';
3
+ import { ServerBackend, ServerUnavailable } from './backends/server.js';
4
+ import { ShellBackend } from './backends/shell.js';
5
+ import { interesting, parseTree, rowRecord, screenInfo } from './tree.js';
6
+ import { cachePath, delay, readJson, serialKey, writeJson } from './util.js';
7
+
8
+ /**
9
+ * One device, and the backend that talks to it: the on-device server when it
10
+ * runs there, `adb shell` otherwise. Commands go through this and never pick a
11
+ * backend themselves.
12
+ */
13
+ export class Device {
14
+ constructor(serial, { server = true, settle } = {}) {
15
+ this.serial = serial;
16
+ this.adb = new Adb(serial);
17
+ this.server = new ServerBackend(this.adb);
18
+ this.shell = new ShellBackend(this.adb);
19
+ this.useServer = server;
20
+ this.settle = settle;
21
+ this.chosen = null;
22
+ }
23
+
24
+ async backend() {
25
+ if (this.chosen) return this.chosen;
26
+ if (this.useServer) {
27
+ try {
28
+ await this.server.ensure();
29
+ return (this.chosen = this.server);
30
+ } catch (e) {
31
+ if (!(e instanceof ServerUnavailable)) throw e;
32
+ if (!e.remembered) process.stderr.write(`adbax: on-device server unavailable (${e.message}); using uiautomator dump\n`);
33
+ }
34
+ }
35
+ return (this.chosen = this.shell);
36
+ }
37
+
38
+ async fast() {
39
+ return (await this.backend()) === this.server;
40
+ }
41
+
42
+ /**
43
+ * The tree as XML. `afterAction` gives the screen time to react first: on
44
+ * the server a short settle and then a wait for accessibility events to
45
+ * stop, on the shell a fixed sleep (uiautomator waits a second on its own).
46
+ */
47
+ async dump({ afterAction = false, windows = false } = {}) {
48
+ const b = await this.backend();
49
+ if (b === this.server) {
50
+ const settle = this.settle !== undefined ? +this.settle : afterAction ? 150 : 0;
51
+ let d = await b.dump({ settle, idle: 300, timeout: afterAction ? 3000 : 2000, windows });
52
+ // A new activity's window can go quiet before its content is attached,
53
+ // which reads as an empty screen. Read again until something shows or
54
+ // two reads agree: a game's canvas stays empty, and that is the answer.
55
+ for (let i = 0; afterAction && i < 4 && interesting(parseTree(d.xml)).length === 0; i++) {
56
+ await delay(250);
57
+ const again = await b.dump({ idle: 300, timeout: 1500, windows });
58
+ if (again.xml === d.xml) break;
59
+ d = again;
60
+ }
61
+ return d;
62
+ }
63
+ if (windows) process.stderr.write('adbax: --windows needs the on-device server; showing the active window only\n');
64
+ const settle = this.settle !== undefined ? +this.settle : afterAction ? 700 : 0;
65
+ return b.dump({ settle });
66
+ }
67
+
68
+ async tree(opts) {
69
+ const { xml } = await this.dump(opts);
70
+ return parseTree(xml);
71
+ }
72
+
73
+ // Polling loops (wait, scroll-to) can go much faster when a read is cheap.
74
+ async pollInterval() {
75
+ return (await this.fast()) ? 150 : 600;
76
+ }
77
+
78
+ async tap(x, y) { return (await this.backend()).tap(x, y); }
79
+ async swipe(x1, y1, x2, y2, ms) { return (await this.backend()).swipe(x1, y1, x2, y2, ms); }
80
+
81
+ async key(codes, opts) { return (await this.backend()).key(codes, opts); }
82
+
83
+ async type(text) { return (await this.backend()).type(text); }
84
+
85
+ /** The field with input focus: {focused, editable, text, password}, or null if unknown. */
86
+ async focus() {
87
+ const b = await this.backend();
88
+ if (b === this.server) return b.focus();
89
+ const f = (await this.tree()).find((n) => n.focused && n.editable);
90
+ return f ? { focused: true, editable: true, text: f.text, password: f.password } : { focused: false };
91
+ }
92
+
93
+ /** Empties the focused field; ACTION_SET_TEXT where possible, a run of DELs where not. */
94
+ async clear(existingLength = 0) {
95
+ const b = await this.backend();
96
+ if (b === this.server && (await b.clear())) return;
97
+ await this.shell.clear(existingLength);
98
+ }
99
+
100
+ /** Replaces the focused field's text. The field's text afterwards, or null without one. */
101
+ async setText(text) {
102
+ const b = await this.backend();
103
+ if (b === this.server) {
104
+ try { return (await b.request({ cmd: 'settext', text })).head.text ?? text; } catch (e) { if (process.env.ADBAX_DEBUG) console.error(e); return null; }
105
+ }
106
+ await this.shell.clear(4096);
107
+ await this.shell.type(text);
108
+ return text;
109
+ }
110
+
111
+ async screenshot(opts) { return (await this.backend()).screenshot(opts); }
112
+
113
+ async keyboardUp() {
114
+ const o = await this.adb.shell('dumpsys input_method | grep -E "mInputShown|mServedView" | head -3', { soft: true });
115
+ return /mInputShown=true/.test(o);
116
+ }
117
+
118
+ /** Waits for a soft keyboard or an editable field with focus, whichever the device reports. */
119
+ async waitForInput(ms = 3000) {
120
+ const deadline = Date.now() + ms;
121
+ const fast = await this.fast();
122
+ while (Date.now() < deadline) {
123
+ if (fast) {
124
+ const f = await this.server.focus();
125
+ if (f.focused && f.editable) return true;
126
+ } else if (await this.keyboardUp()) {
127
+ await delay(250);
128
+ return true;
129
+ }
130
+ await delay(fast ? 80 : 200);
131
+ }
132
+ return false;
133
+ }
134
+
135
+ /**
136
+ * "package/Activity" on top. From the server when it runs (a few ms), from
137
+ * dumpsys otherwise (60-200 ms).
138
+ */
139
+ async where() {
140
+ if (this.chosen === this.server) {
141
+ const a = await this.server.activity().catch(() => null);
142
+ if (a) return a;
143
+ }
144
+ // `dumpsys window displays` is the cheap section; Android 8 and older only
145
+ // print mCurrentFocus in the full dump.
146
+ let w = (await this.adb.shell('dumpsys window displays 2>/dev/null | grep -m1 mCurrentFocus', { soft: true })).trim();
147
+ if (!w) w = (await this.adb.shell('dumpsys window 2>/dev/null | grep -m1 mCurrentFocus', { soft: true })).trim();
148
+ const m = w.match(/([\w.]+)\/([\w.$]+)/);
149
+ return m ? `${m[1]}/${m[2]}` : (w || '?');
150
+ }
151
+
152
+ /** The screen as currently rotated: a landscape game is wider than it is tall. */
153
+ async screenSize(xml) {
154
+ const info = xml ? screenInfo(xml) : {};
155
+ if (info.width) return { width: info.width, height: info.height };
156
+ if ((await this.fast()) && this.server.info && this.server.info.width) {
157
+ return { width: this.server.info.width, height: this.server.info.height };
158
+ }
159
+ const m = (await this.adb.shell('wm size', { soft: true })).match(/(\d+)x(\d+)(?![\s\S]*\d+x\d+)/);
160
+ if (!m) return { width: 1080, height: 1920 };
161
+ // `wm size` is the physical, unrotated size.
162
+ const rot = (await this.adb.shell("dumpsys input | grep -m1 SurfaceOrientation", { soft: true })).match(/(\d)\s*$/);
163
+ const turned = rot && (rot[1] === '1' || rot[1] === '3');
164
+ return turned ? { width: +m[2], height: +m[1] } : { width: +m[1], height: +m[2] };
165
+ }
166
+
167
+ // The rows of the last `ui`, so `tap 3` can skip a fresh dump.
168
+ get rowsFile() {
169
+ return cachePath(`ui-${serialKey(this.serial)}.json`);
170
+ }
171
+
172
+ saveRows(nodes, all = false) {
173
+ const rows = interesting(nodes, all);
174
+ writeJson(this.rowsFile, rows.map(rowRecord));
175
+ return rows;
176
+ }
177
+
178
+ lastRows() {
179
+ return readJson(this.rowsFile);
180
+ }
181
+
182
+ // The last screenshot's size, so `--shot` can map its pixels to the screen.
183
+ get shotFile() {
184
+ return cachePath(`shot-${serialKey(this.serial)}.json`);
185
+ }
186
+
187
+ saveShot(info) {
188
+ writeJson(this.shotFile, info);
189
+ }
190
+
191
+ /** Maps a point in the last screenshot's pixels to screen pixels. */
192
+ fromShot(x, y) {
193
+ const s = readJson(this.shotFile);
194
+ if (!s) return null;
195
+ return [Math.round(x * s.deviceWidth / s.width), Math.round(y * s.deviceHeight / s.height)];
196
+ }
197
+ }