@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.
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +132 -2
- package/bin/adbax.js +15 -0
- package/docs/architecture.md +166 -0
- package/docs/commands.md +163 -0
- package/package.json +18 -4
- package/server/adbax-server.jar +0 -0
- package/skill/SKILL.md +143 -0
- package/src/adb.js +120 -0
- package/src/args.js +37 -0
- package/src/backends/server.js +297 -0
- package/src/backends/shell.js +69 -0
- package/src/cli.js +80 -0
- package/src/commands/apps.js +90 -0
- package/src/commands/common.js +39 -0
- package/src/commands/device.js +73 -0
- package/src/commands/screen.js +112 -0
- package/src/commands/server.js +34 -0
- package/src/commands/system.js +102 -0
- package/src/commands/touch.js +210 -0
- package/src/device.js +197 -0
- package/src/select.js +95 -0
- package/src/tree.js +183 -0
- package/src/util.js +56 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/*
|
|
3
|
+
* Host half of the on-device server (server/src). The server is a dex jar run
|
|
4
|
+
* through app_process as the shell user. It keeps one UiAutomation connection
|
|
5
|
+
* open and answers on an abstract unix socket that adb forwards to a local TCP
|
|
6
|
+
* port. Because the connection stays up, it has seen every accessibility event
|
|
7
|
+
* since it started and knows when the screen went quiet. A read on a settled
|
|
8
|
+
* screen costs tens of milliseconds instead of a second of forced waiting.
|
|
9
|
+
*
|
|
10
|
+
* The jar's MD5 is the version handshake: the host passes it as --token, the
|
|
11
|
+
* server echoes it, and a server answering with another token is an older
|
|
12
|
+
* build and gets replaced.
|
|
13
|
+
*/
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { adbRaw } from '../adb.js';
|
|
19
|
+
import { AdbaxError, cachePath, delay, q, readJson, readText, serialKey, trace, writeJson } from '../util.js';
|
|
20
|
+
|
|
21
|
+
const SOCKET = 'adbax';
|
|
22
|
+
const PROCESS = 'adbax-server';
|
|
23
|
+
const REMOTE_JAR = '/data/local/tmp/adbax-server.jar';
|
|
24
|
+
const REMOTE_LOG = '/data/local/tmp/adbax-server.log';
|
|
25
|
+
const MIN_SDK = 24;
|
|
26
|
+
export const JAR = fileURLToPath(new URL('../../server/adbax-server.jar', import.meta.url));
|
|
27
|
+
|
|
28
|
+
let jarMd5;
|
|
29
|
+
function token() {
|
|
30
|
+
if (!jarMd5) {
|
|
31
|
+
if (!fs.existsSync(JAR)) throw new ServerUnavailable(`${JAR} is missing; run server/build.sh`);
|
|
32
|
+
jarMd5 = crypto.createHash('md5').update(fs.readFileSync(JAR)).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
return jarMd5;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Seconds without a request before the server exits and frees the connection. */
|
|
38
|
+
export const idleSeconds = () => +(process.env.ADBAX_SERVER_IDLE || 600);
|
|
39
|
+
|
|
40
|
+
/** The server cannot run here; the caller falls back to the shell backend. */
|
|
41
|
+
export class ServerUnavailable extends AdbaxError {
|
|
42
|
+
constructor(message, { remembered = false } = {}) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.remembered = remembered;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class ServerBackend {
|
|
49
|
+
constructor(adb) {
|
|
50
|
+
this.adb = adb;
|
|
51
|
+
this.name = 'server';
|
|
52
|
+
this.port = null;
|
|
53
|
+
this.ready = false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get markerFile() {
|
|
57
|
+
return cachePath(`server-${serialKey(this.adb.serial)}.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The local port adb already forwards to the server socket, or null. */
|
|
61
|
+
async findPort() {
|
|
62
|
+
const out = await adbRaw(['forward', '--list'], { soft: true });
|
|
63
|
+
for (const line of out.split('\n')) {
|
|
64
|
+
const [serial, local, remote] = line.trim().split(/\s+/);
|
|
65
|
+
if (serial === this.adb.serial && remote === `localabstract:${SOCKET}`) return +local.replace('tcp:', '');
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get portFile() {
|
|
71
|
+
return cachePath(`port-${serialKey(this.adb.serial)}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async forward() {
|
|
75
|
+
const port = (await this.findPort()) || +(await this.adb.run(['forward', 'tcp:0', `localabstract:${SOCKET}`])).trim();
|
|
76
|
+
fs.writeFileSync(this.portFile, String(port));
|
|
77
|
+
return port;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A server answering for this device and this build. */
|
|
81
|
+
current(res) {
|
|
82
|
+
return !!res && res.head.token === token() && res.head.serial === this.adb.serial;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One request, one connection. Resolves {head, body}, or null when nothing
|
|
87
|
+
* answered: adb accepts a forwarded connection even with no server behind
|
|
88
|
+
* it and then closes it straight away.
|
|
89
|
+
*/
|
|
90
|
+
send(req, timeoutMs = 15_000) {
|
|
91
|
+
const started = performance.now();
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const sock = net.connect(this.port, '127.0.0.1');
|
|
94
|
+
const chunks = [];
|
|
95
|
+
const timer = setTimeout(() => sock.destroy(), timeoutMs);
|
|
96
|
+
sock.on('connect', () => sock.write(JSON.stringify(req) + '\n'));
|
|
97
|
+
sock.on('data', (c) => chunks.push(c));
|
|
98
|
+
sock.on('error', () => {});
|
|
99
|
+
sock.on('close', () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
trace(`server ${req.cmd}`, started);
|
|
102
|
+
const buf = Buffer.concat(chunks);
|
|
103
|
+
const nl = buf.indexOf(10);
|
|
104
|
+
if (nl < 0) return resolve(null);
|
|
105
|
+
try {
|
|
106
|
+
resolve({ head: JSON.parse(buf.subarray(0, nl).toString('utf8')), body: buf.subarray(nl + 1) });
|
|
107
|
+
} catch {
|
|
108
|
+
resolve(null);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Makes sure a current server answers, starting one if needed. Returns its ping. */
|
|
115
|
+
async ensure() {
|
|
116
|
+
if (this.ready) return;
|
|
117
|
+
// The last port saves an `adb forward --list`; the serial echo catches a
|
|
118
|
+
// port that adb has since handed to another device.
|
|
119
|
+
const cached = +readText(this.portFile);
|
|
120
|
+
let ping = null;
|
|
121
|
+
if (cached) {
|
|
122
|
+
this.port = cached;
|
|
123
|
+
ping = await this.send({ cmd: 'ping' }, 3000);
|
|
124
|
+
if (ping && ping.head.serial !== this.adb.serial) ping = null;
|
|
125
|
+
}
|
|
126
|
+
if (!ping) {
|
|
127
|
+
this.port = await this.forward();
|
|
128
|
+
ping = await this.send({ cmd: 'ping' }, 3000);
|
|
129
|
+
}
|
|
130
|
+
if (this.current(ping)) {
|
|
131
|
+
this.ready = true;
|
|
132
|
+
this.info = ping.head;
|
|
133
|
+
fs.rmSync(this.markerFile, { force: true });
|
|
134
|
+
return ping.head;
|
|
135
|
+
}
|
|
136
|
+
if (ping && ping.head.serial === this.adb.serial) await this.send({ cmd: 'quit' }, 3000);
|
|
137
|
+
const head = await this.start();
|
|
138
|
+
this.ready = true;
|
|
139
|
+
this.info = head;
|
|
140
|
+
return head;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async request(req, timeoutMs) {
|
|
144
|
+
await this.ensure();
|
|
145
|
+
let res = await this.send(req, timeoutMs);
|
|
146
|
+
if (!res) {
|
|
147
|
+
// It exited between requests (idle timeout, killed). Start it once more.
|
|
148
|
+
this.ready = false;
|
|
149
|
+
await this.ensure();
|
|
150
|
+
res = await this.send(req, timeoutMs);
|
|
151
|
+
}
|
|
152
|
+
if (!res) throw new AdbaxError(`server gave no answer to ${req.cmd}; see \`adbax server log\``);
|
|
153
|
+
if (!res.head.ok) throw new AdbaxError(res.head.error);
|
|
154
|
+
return res;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async start({ idle = idleSeconds(), force = false } = {}) {
|
|
158
|
+
const marker = readJson(this.markerFile);
|
|
159
|
+
if (!force && marker && marker.token === token() && marker.unsupported) {
|
|
160
|
+
throw new ServerUnavailable(marker.unsupported, { remembered: true });
|
|
161
|
+
}
|
|
162
|
+
const sdk = +(await this.adb.shell('getprop ro.build.version.sdk')).trim();
|
|
163
|
+
if (sdk < MIN_SDK) this.unsupported(`Android API ${sdk}; the server needs ${MIN_SDK} or newer`);
|
|
164
|
+
|
|
165
|
+
const remote = (await this.adb.shell(`md5sum ${REMOTE_JAR} 2>/dev/null`, { soft: true })).split(/\s/)[0];
|
|
166
|
+
if (remote !== token()) await this.adb.run(['push', JAR, REMOTE_JAR]);
|
|
167
|
+
|
|
168
|
+
// A server that no longer answers on our forward may still hold the
|
|
169
|
+
// accessibility connection, and only one client can have it.
|
|
170
|
+
await this.kill();
|
|
171
|
+
await this.adb.shell(`CLASSPATH=${REMOTE_JAR} nohup app_process / --nice-name=${PROCESS} ` +
|
|
172
|
+
`es.flx.adbax.Main --socket ${SOCKET} --token ${token()} --serial ${q(this.adb.serial)} --idle ${idle} ` +
|
|
173
|
+
`>${REMOTE_LOG} 2>&1 &`);
|
|
174
|
+
this.port = await this.forward();
|
|
175
|
+
|
|
176
|
+
const deadline = Date.now() + 10_000;
|
|
177
|
+
for (let i = 0; Date.now() < deadline; i++) {
|
|
178
|
+
await delay(i < 10 ? 60 : 200);
|
|
179
|
+
const ping = await this.send({ cmd: 'ping' }, 2000);
|
|
180
|
+
if (this.current(ping)) {
|
|
181
|
+
fs.rmSync(this.markerFile, { force: true });
|
|
182
|
+
return ping.head;
|
|
183
|
+
}
|
|
184
|
+
if (i < 4) continue;
|
|
185
|
+
const log = (await this.adb.shell(`cat ${REMOTE_LOG}`, { soft: true })).trim();
|
|
186
|
+
const crashed = /Exception|Error/.test(log);
|
|
187
|
+
// Until the VM applies --nice-name the process is still "app_process",
|
|
188
|
+
// so only call it dead after a grace period, or on a stack trace.
|
|
189
|
+
if (crashed || (Date.now() > deadline - 7000 && !(await this.running()))) {
|
|
190
|
+
if (/already registered/i.test(log)) {
|
|
191
|
+
throw new ServerUnavailable('another UiAutomation client holds the accessibility connection ' +
|
|
192
|
+
'(an instrumentation test, uiautomator, Appium, Maestro). Stop it, or pass --no-server.');
|
|
193
|
+
}
|
|
194
|
+
const reason = log.split('\n').find((l) => /Exception|Error/.test(l)) || log.split('\n')[0] || 'exited at start';
|
|
195
|
+
await this.kill();
|
|
196
|
+
this.unsupported(`server failed to start: ${reason.trim()}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
throw new ServerUnavailable('server did not answer within 10 s; see `adbax server log`');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
unsupported(reason) {
|
|
203
|
+
writeJson(this.markerFile, { token: token(), unsupported: reason, at: new Date().toISOString() });
|
|
204
|
+
throw new ServerUnavailable(reason);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async pid() {
|
|
208
|
+
return (await this.adb.shell(`pidof ${PROCESS}`, { soft: true })).trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Matches the command line rather than the process name, so it also finds a
|
|
213
|
+
* server still starting up. The brackets keep the pattern from matching the
|
|
214
|
+
* shell that runs it.
|
|
215
|
+
*/
|
|
216
|
+
async running() {
|
|
217
|
+
return (await this.adb.shell(`pgrep -f '[a]dbx-server'`, { soft: true })).trim() !== '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async kill() {
|
|
221
|
+
await this.adb.shell(`pkill -f '[a]dbx-server'; sleep 0.1`, { soft: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Ping without starting anything. */
|
|
225
|
+
async status() {
|
|
226
|
+
this.port = await this.findPort();
|
|
227
|
+
const pid = await this.pid();
|
|
228
|
+
if (!this.port) return { running: !!pid, pid, port: null };
|
|
229
|
+
const ping = await this.send({ cmd: 'ping' }, 3000);
|
|
230
|
+
return { running: !!ping, pid, port: this.port, ping: ping && ping.head, current: this.current(ping) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async stop() {
|
|
234
|
+
this.port = await this.findPort();
|
|
235
|
+
if (this.port) await this.send({ cmd: 'quit' }, 3000);
|
|
236
|
+
await this.kill();
|
|
237
|
+
if (this.port) await this.adb.run(['forward', '--remove', `tcp:${this.port}`], { soft: true });
|
|
238
|
+
fs.rmSync(this.portFile, { force: true });
|
|
239
|
+
this.ready = false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
log() {
|
|
243
|
+
return this.adb.shell(`cat ${REMOTE_LOG}`, { soft: true });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------- backend
|
|
247
|
+
|
|
248
|
+
async dump({ settle = 0, idle = 300, timeout = 2000, windows = false } = {}) {
|
|
249
|
+
const res = await this.request({ cmd: 'dump', settle, idle, timeout, windows }, settle + timeout + 10_000);
|
|
250
|
+
return { xml: res.body.toString('utf8'), busy: res.head.busy, head: res.head };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
tap(x, y) {
|
|
254
|
+
return this.request({ cmd: 'tap', x, y });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
swipe(x1, y1, x2, y2, ms) {
|
|
258
|
+
return this.request({ cmd: 'swipe', x1, y1, x2, y2, ms }, ms + 10_000);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async key(codes, { long = false } = {}) {
|
|
262
|
+
for (const code of codes) {
|
|
263
|
+
await this.request(typeof code === 'number' ? { cmd: 'key', code, long } : { cmd: 'key', name: String(code).toUpperCase(), long });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Key events where the virtual keyboard map has the characters, ACTION_SET_TEXT where not. */
|
|
268
|
+
async type(text) {
|
|
269
|
+
return { via: (await this.request({ cmd: 'text', text })).head.via };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Empties the focused field. Returns false when it has none. */
|
|
273
|
+
async clear() {
|
|
274
|
+
try {
|
|
275
|
+
await this.request({ cmd: 'settext', text: '' });
|
|
276
|
+
return true;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async focus() {
|
|
283
|
+
return (await this.request({ cmd: 'focus' })).head;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async activity() {
|
|
287
|
+
return (await this.request({ cmd: 'activity' })).head.activity || null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async screenshot({ width = 0, grid = 0, format = 'png', quality = 85 } = {}) {
|
|
291
|
+
const res = await this.request({ cmd: 'screenshot', width, grid, format, quality }, 20_000);
|
|
292
|
+
return {
|
|
293
|
+
data: res.body, format: res.head.format, width: res.head.width, height: res.head.height, scaled: true,
|
|
294
|
+
screenWidth: res.head.screenWidth, screenHeight: res.head.screenHeight,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/*
|
|
3
|
+
* The fallback backend: plain `adb shell` commands. Works on any device, but
|
|
4
|
+
* every read is a fresh `uiautomator dump`, which starts a VM, connects to the
|
|
5
|
+
* accessibility service and waits a full second for the screen to go idle, so
|
|
6
|
+
* about two seconds per read.
|
|
7
|
+
*/
|
|
8
|
+
import { delay, fail, q } from '../util.js';
|
|
9
|
+
|
|
10
|
+
export class ShellBackend {
|
|
11
|
+
constructor(adb) {
|
|
12
|
+
this.adb = adb;
|
|
13
|
+
this.name = 'shell';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async dump({ settle = 0 } = {}) {
|
|
17
|
+
if (settle) await delay(settle);
|
|
18
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
19
|
+
const out = await this.adb.run(['exec-out', 'uiautomator', 'dump', '/dev/tty'], { soft: true, timeout: 30_000 });
|
|
20
|
+
const i = out.indexOf('<hierarchy');
|
|
21
|
+
if (i > -1 && out.includes('</hierarchy>')) return { xml: out.slice(i), busy: false };
|
|
22
|
+
// Some builds refuse /dev/tty; go through a file instead. Removed first,
|
|
23
|
+
// so a dump that fails cannot hand back the previous screen.
|
|
24
|
+
const f = await this.adb.shell(
|
|
25
|
+
'rm -f /sdcard/adbax.xml; uiautomator dump /sdcard/adbax.xml >/dev/null && cat /sdcard/adbax.xml', { soft: true });
|
|
26
|
+
const j = f.indexOf('<hierarchy');
|
|
27
|
+
if (j > -1) return { xml: f.slice(j), busy: false };
|
|
28
|
+
await delay(700);
|
|
29
|
+
}
|
|
30
|
+
const holder = (await this.adb.shell('pidof adbax-server', { soft: true })).trim();
|
|
31
|
+
fail('uiautomator dump failed 3x. ' + (holder
|
|
32
|
+
? 'The adbax server is holding the accessibility connection; `adbax server stop` first.'
|
|
33
|
+
: 'The screen may be mid-animation, showing a secure surface, or the app draws no\n' +
|
|
34
|
+
' accessibility nodes. Try `adbax screenshot --small`.'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
tap(x, y) {
|
|
38
|
+
return this.adb.shell(`input tap ${x} ${y}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
swipe(x1, y1, x2, y2, ms) {
|
|
42
|
+
return this.adb.shell(`input swipe ${x1} ${y1} ${x2} ${y2} ${ms}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
key(codes, { long = false } = {}) {
|
|
46
|
+
return this.adb.shell(`input keyevent ${long ? '--longpress ' : ''}${codes.map(q).join(' ')}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `input text` takes ASCII only on most builds; the caller warns about the rest. */
|
|
50
|
+
async type(text) {
|
|
51
|
+
await this.adb.shell(`input text ${q(text.replace(/ /g, '%s'))}`);
|
|
52
|
+
return { via: 'input' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async clear(existingLength) {
|
|
56
|
+
await this.adb.shell('input keyevent KEYCODE_MOVE_END');
|
|
57
|
+
const n = Math.min(200, Math.max(existingLength + 5, 60));
|
|
58
|
+
await this.adb.shell('input keyevent ' + Array(n).fill('KEYCODE_DEL').join(' '));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The shell has no cheap way to ask; callers fall back to a dump. */
|
|
62
|
+
focus() {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async screenshot() {
|
|
67
|
+
return { data: await this.adb.execOut('screencap -p'), format: 'png', scaled: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { parseArgs } from './args.js';
|
|
3
|
+
import { resolveSerial } from './adb.js';
|
|
4
|
+
import { Device } from './device.js';
|
|
5
|
+
import { AdbaxError } from './util.js';
|
|
6
|
+
import * as apps from './commands/apps.js';
|
|
7
|
+
import * as device from './commands/device.js';
|
|
8
|
+
import * as screen from './commands/screen.js';
|
|
9
|
+
import * as server from './commands/server.js';
|
|
10
|
+
import * as system from './commands/system.js';
|
|
11
|
+
import * as touch from './commands/touch.js';
|
|
12
|
+
|
|
13
|
+
const COMMANDS = {
|
|
14
|
+
...device, ...screen, ...apps, ...system, ...server,
|
|
15
|
+
...touch, 'scroll-to': touch.scrollTo, 'hide-keyboard': touch.hideKeyboard,
|
|
16
|
+
press: touch.longpress, text: touch.type, screencap: screen.screenshot, logcat: system.log, ls: apps.apps,
|
|
17
|
+
};
|
|
18
|
+
delete COMMANDS.scrollTo;
|
|
19
|
+
delete COMMANDS.hideKeyboard;
|
|
20
|
+
|
|
21
|
+
export const HELP = `adbax - drive an Android device or emulator by its accessibility tree
|
|
22
|
+
|
|
23
|
+
DEVICE devices | use <serial|-> | avds | boot <avd> [--cold --wipe --no-window] | kill [--all]
|
|
24
|
+
SEE ui [--all --full --json --raw --windows] | find <text> | inspect <text> | current
|
|
25
|
+
screenshot [path] [--small --width N --grid [step]] (a .jpg path saves JPEG)
|
|
26
|
+
TOUCH tap <text|n|x,y> | longpress <sel> [--dur ms] | type <text> [--into <sel>] [--clear --set --enter]
|
|
27
|
+
key <back|home|enter|recents|del|...|KEYCODE_*> [--long] | back | home | recents | hide-keyboard
|
|
28
|
+
swipe <up|down|left|right|x1,y1,x2,y2> [--dur ms] [--in <sel>]
|
|
29
|
+
scroll-to <text> [--max 12] [--dir up|down] [--tap] | wait <text> [--timeout 15] [--gone]
|
|
30
|
+
APPS apps [filter] [--system] | launch <pkg> | start <pkg/.Act> | stop <pkg> | clear <pkg>
|
|
31
|
+
install <apk> [splits...] | uninstall <pkg> | deeplink <url> [pkg] | perm <grant|revoke> <pkg> <PERM> | perms <pkg>
|
|
32
|
+
DEBUG log [pkg] [--lines N --grep re --level E] | crash | clearlog | record [secs] [path]
|
|
33
|
+
STATE dark <on|off> | font <scale> | anim <on|off> | rotate <0|90|180|270|auto> | demo <on|off>
|
|
34
|
+
SERVER server [status|start|stop|restart|log] [--idle seconds]
|
|
35
|
+
RAW shell <cmd> | pull <remote> <local> | push <local> <remote> | version
|
|
36
|
+
|
|
37
|
+
Global: -s <serial>, --ui (show the screen after acting), --settle <ms>, --nth N, --first, --re,
|
|
38
|
+
--shot (x,y are pixels of the last screenshot, not of the screen),
|
|
39
|
+
--no-server (plain adb shell and uiautomator, as on devices the server cannot run on)
|
|
40
|
+
|
|
41
|
+
Selectors match text, content-desc and resource-id; a match that is not itself
|
|
42
|
+
clickable resolves to its nearest clickable ancestor. An ambiguous selector
|
|
43
|
+
exits 2 and lists the candidates rather than guessing.
|
|
44
|
+
|
|
45
|
+
adbax ui
|
|
46
|
+
adbax tap "Sign in" --ui
|
|
47
|
+
adbax type you@example.com --into Email --enter
|
|
48
|
+
adbax scroll-to "Delete account" --tap
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
export async function main(argv) {
|
|
52
|
+
const { cmd, pos, flags } = parseArgs(argv);
|
|
53
|
+
if (!cmd || cmd === 'help' || flags.help) {
|
|
54
|
+
process.stdout.write(HELP);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (cmd === 'version' || flags.version) {
|
|
58
|
+
const { createRequire } = await import('node:module');
|
|
59
|
+
process.stdout.write(createRequire(import.meta.url)('../package.json').version + '\n');
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
const run = COMMANDS[cmd];
|
|
63
|
+
if (!run) throw new AdbaxError(`unknown command "${cmd}". Run \`adbax help\`.`);
|
|
64
|
+
|
|
65
|
+
let dev = null;
|
|
66
|
+
const ctx = {
|
|
67
|
+
pos,
|
|
68
|
+
flags,
|
|
69
|
+
out: (s) => process.stdout.write(s + '\n'),
|
|
70
|
+
async device() {
|
|
71
|
+
if (!dev) {
|
|
72
|
+
const serverOff = flags['no-server'] || /^(0|off|false|no)$/i.test(process.env.ADBAX_SERVER || '');
|
|
73
|
+
dev = new Device(await resolveSerial(flags), { server: !serverOff, settle: flags.settle });
|
|
74
|
+
}
|
|
75
|
+
return dev;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
await run(ctx);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fail, q } from '../util.js';
|
|
4
|
+
import { maybeUi } from './common.js';
|
|
5
|
+
|
|
6
|
+
export async function apps(ctx) {
|
|
7
|
+
const filter = (ctx.pos[0] || '').toLowerCase();
|
|
8
|
+
const dev = await ctx.device();
|
|
9
|
+
const out = await dev.adb.shell(`pm list packages ${ctx.flags.system ? '' : '-3'}`, { soft: true });
|
|
10
|
+
ctx.out(out.split('\n').map((l) => l.replace('package:', '').trim()).filter(Boolean)
|
|
11
|
+
.filter((p) => !filter || p.toLowerCase().includes(filter)).sort().join('\n'));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Starts the launcher activity and waits until it is drawn (`am start -W`). */
|
|
15
|
+
export async function launch(ctx) {
|
|
16
|
+
const pkg = ctx.pos[0] || fail('usage: adbax launch <package>');
|
|
17
|
+
const dev = await ctx.device();
|
|
18
|
+
const comp = (await dev.adb.shell(
|
|
19
|
+
`cmd package resolve-activity --brief -c android.intent.category.LAUNCHER ${q(pkg)} | tail -n 1`, { soft: true })).trim();
|
|
20
|
+
if (comp.includes('/')) await dev.adb.shell(`am start -W -n ${q(comp)}`, { soft: true, timeout: 30_000 });
|
|
21
|
+
else {
|
|
22
|
+
const out = await dev.adb.shell(`monkey -p ${q(pkg)} -c android.intent.category.LAUNCHER 1 2>&1 || true`, { soft: true });
|
|
23
|
+
if (/No activities found/i.test(out)) fail(`${pkg} is not installed or has no launcher activity. \`adbax apps\` lists packages.`, 2);
|
|
24
|
+
}
|
|
25
|
+
ctx.out(`launched ${pkg} -> ${await dev.where()}`);
|
|
26
|
+
await maybeUi(ctx);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function start(ctx) {
|
|
30
|
+
const comp = ctx.pos[0] || fail('usage: adbax start <pkg/.Activity>');
|
|
31
|
+
const dev = await ctx.device();
|
|
32
|
+
ctx.out((await dev.adb.shell(`am start -W -n ${q(comp)}`)).trim());
|
|
33
|
+
await maybeUi(ctx);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function stop(ctx) {
|
|
37
|
+
const pkg = ctx.pos[0] || fail('usage: adbax stop <package>');
|
|
38
|
+
await (await ctx.device()).adb.shell(`am force-stop ${q(pkg)}`);
|
|
39
|
+
ctx.out('stopped ' + pkg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function clear(ctx) {
|
|
43
|
+
const pkg = ctx.pos[0] || fail('usage: adbax clear <package>');
|
|
44
|
+
ctx.out((await (await ctx.device()).adb.shell(`pm clear ${q(pkg)}`)).trim());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* -r -g -t: replace, grant runtime permissions, allow test builds. Several
|
|
49
|
+
* files (a base APK plus splits) go in as one install-multiple.
|
|
50
|
+
*/
|
|
51
|
+
export async function install(ctx) {
|
|
52
|
+
if (!ctx.pos.length) fail('usage: adbax install <file.apk> [split.apk ...]');
|
|
53
|
+
const files = ctx.pos.map((f) => path.resolve(f));
|
|
54
|
+
const dev = await ctx.device();
|
|
55
|
+
const verb = files.length > 1 ? 'install-multiple' : 'install';
|
|
56
|
+
ctx.out((await dev.adb.run([verb, '-r', '-g', '-t', ...files], { timeout: 300_000 })).trim());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function uninstall(ctx) {
|
|
60
|
+
const pkg = ctx.pos[0] || fail('usage: adbax uninstall <package>');
|
|
61
|
+
ctx.out((await (await ctx.device()).adb.run(['uninstall', pkg])).trim());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function deeplink(ctx) {
|
|
65
|
+
const url = ctx.pos[0] || fail('usage: adbax deeplink <url> [package]');
|
|
66
|
+
const dev = await ctx.device();
|
|
67
|
+
const pkg = ctx.pos[1] ? ` -p ${q(ctx.pos[1])}` : '';
|
|
68
|
+
ctx.out((await dev.adb.shell(`am start -W -a android.intent.action.VIEW -d ${q(url)}${pkg}`, { soft: true })).trim());
|
|
69
|
+
ctx.out('-> ' + await dev.where());
|
|
70
|
+
await maybeUi(ctx);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function perm(ctx) {
|
|
74
|
+
const [action, pkg, ...perms] = ctx.pos;
|
|
75
|
+
if (!['grant', 'revoke'].includes(action) || !pkg || !perms.length) {
|
|
76
|
+
fail('usage: adbax perm <grant|revoke> <package> <PERMISSION...>');
|
|
77
|
+
}
|
|
78
|
+
const dev = await ctx.device();
|
|
79
|
+
for (const p of perms) {
|
|
80
|
+
const full = p.includes('.') ? p : 'android.permission.' + p.toUpperCase();
|
|
81
|
+
await dev.adb.shell(`pm ${action} ${q(pkg)} ${q(full)}`, { soft: true });
|
|
82
|
+
ctx.out(`${action} ${full}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function perms(ctx) {
|
|
87
|
+
const pkg = ctx.pos[0] || fail('usage: adbax perms <package>');
|
|
88
|
+
const out = await (await ctx.device()).adb.shell(`dumpsys package ${q(pkg)} | sed -n '/runtime permissions:/,/^$/p'`, { soft: true });
|
|
89
|
+
ctx.out(out.trim() || '(none granted)');
|
|
90
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { parseTree, render, rowRecord } from '../tree.js';
|
|
3
|
+
|
|
4
|
+
/** What resolve() needs from a device. */
|
|
5
|
+
export const selectorSource = (dev) => ({
|
|
6
|
+
nodes: () => dev.tree(),
|
|
7
|
+
lastRows: () => dev.lastRows(),
|
|
8
|
+
where: () => dev.where(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const selectorOptions = (flags) => ({ nth: flags.nth, first: !!flags.first, regex: !!flags.re });
|
|
12
|
+
|
|
13
|
+
/** Prints the screen. The server names the top activity with the dump; the shell asks dumpsys. */
|
|
14
|
+
export async function showUi(ctx, { afterAction = false } = {}) {
|
|
15
|
+
const { flags } = ctx;
|
|
16
|
+
const dev = await ctx.device();
|
|
17
|
+
const dump = await dev.dump({ afterAction, windows: !!flags.windows });
|
|
18
|
+
if (flags.raw) return ctx.out(dump.xml);
|
|
19
|
+
const nodes = parseTree(dump.xml);
|
|
20
|
+
const rows = dev.saveRows(nodes, !!flags.all);
|
|
21
|
+
if (flags.json) return ctx.out(JSON.stringify(rows.map(rowRecord), null, 2));
|
|
22
|
+
const [where, size] = await Promise.all([(dump.head && dump.head.activity) || dev.where(), dev.screenSize(dump.xml)]);
|
|
23
|
+
ctx.out(`# ${where} ${size.width}x${size.height} ${rows.length} nodes` +
|
|
24
|
+
(flags.all ? ' (--all)' : '') + (dump.busy ? ' (still changing: read before it went quiet)' : ''));
|
|
25
|
+
ctx.out(render(rows, { full: !!flags.full }));
|
|
26
|
+
if (!flags.all) ctx.out('# tap by text: adbax tap "Label" or by index: adbax tap 3 (--all shows every node)');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `--ui` on an action: show the screen it led to. */
|
|
30
|
+
export async function maybeUi(ctx) {
|
|
31
|
+
if (ctx.flags.ui) await showUi(ctx, { afterAction: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const KEYS = {
|
|
35
|
+
back: 4, home: 3, recents: 187, apps: 187, enter: 66, tab: 61, del: 67, delete: 67,
|
|
36
|
+
esc: 111, escape: 111, up: 19, down: 20, left: 21, right: 22, menu: 82, search: 84,
|
|
37
|
+
power: 26, volup: 24, voldown: 25, camera: 27, space: 62, dpad_center: 23, ok: 23,
|
|
38
|
+
wake: 224, sleep: 223, notifications: 83, mute: 164, call: 5, endcall: 6, paste: 279,
|
|
39
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { adbRaw, emulatorBinary, listDevices, pinSerial, pinnedSerial, runEmulatorSync } from '../adb.js';
|
|
5
|
+
import { cachePath, delay, fail } from '../util.js';
|
|
6
|
+
|
|
7
|
+
export async function devices(ctx) {
|
|
8
|
+
const list = await listDevices();
|
|
9
|
+
if (!list.length) return ctx.out('no devices');
|
|
10
|
+
const pin = pinnedSerial();
|
|
11
|
+
for (const d of list) ctx.out(`${d.serial === pin ? '*' : ' '} ${d.serial.padEnd(22)} ${d.state.padEnd(10)} ${d.model}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function use(ctx) {
|
|
15
|
+
const s = ctx.pos[0];
|
|
16
|
+
if (!s || s === '-') {
|
|
17
|
+
pinSerial(null);
|
|
18
|
+
return ctx.out('device pin cleared');
|
|
19
|
+
}
|
|
20
|
+
pinSerial(s);
|
|
21
|
+
ctx.out('pinned ' + s);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function avds(ctx) {
|
|
25
|
+
ctx.out(runEmulatorSync(['-list-avds']).trim());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Boots an AVD, waits for sys.boot_completed, unlocks it and pins it. */
|
|
29
|
+
export async function boot(ctx) {
|
|
30
|
+
const { flags } = ctx;
|
|
31
|
+
const avd = ctx.pos[0] || fail('usage: adbax boot <avd> [--cold] [--wipe] [--no-window] (see `adbax avds`)');
|
|
32
|
+
const before = new Set((await listDevices()).map((d) => d.serial));
|
|
33
|
+
const args = ['-avd', avd];
|
|
34
|
+
if (flags.cold) args.push('-no-snapshot-load');
|
|
35
|
+
if (flags.wipe) args.push('-wipe-data');
|
|
36
|
+
if (flags['no-window']) args.push('-no-window', '-no-audio');
|
|
37
|
+
const logFile = cachePath(`emulator-${avd}.log`);
|
|
38
|
+
const log = fs.openSync(logFile, 'w');
|
|
39
|
+
// The launcher execs into the emulator itself, so an exit here is the
|
|
40
|
+
// emulator giving up: an unknown AVD name, or one that is already running.
|
|
41
|
+
const child = spawn(emulatorBinary(), args, { detached: true, stdio: ['ignore', log, log] });
|
|
42
|
+
let exitCode = null;
|
|
43
|
+
child.on('exit', (code) => { exitCode = code; });
|
|
44
|
+
child.unref();
|
|
45
|
+
process.stderr.write('booting ' + avd);
|
|
46
|
+
let serial = null;
|
|
47
|
+
for (let i = 0; i < 180; i++) {
|
|
48
|
+
await delay(2000);
|
|
49
|
+
if (exitCode) fail(`\nthe emulator exited with status ${exitCode}. Log: ${logFile}`);
|
|
50
|
+
process.stderr.write('.');
|
|
51
|
+
if (!serial) serial = ((await listDevices()).find((d) => !before.has(d.serial)) || {}).serial || null;
|
|
52
|
+
if (!serial) continue;
|
|
53
|
+
const booted = (await adbRaw(['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], { soft: true })).trim();
|
|
54
|
+
if (booted === '1') {
|
|
55
|
+
await adbRaw(['-s', serial, 'shell', 'input', 'keyevent', '82'], { soft: true });
|
|
56
|
+
pinSerial(serial);
|
|
57
|
+
process.stderr.write('\n');
|
|
58
|
+
return ctx.out(`booted ${serial} (pinned)`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
fail(`\ntimed out after 6 min. Log: ${logFile}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function kill(ctx) {
|
|
65
|
+
if (ctx.flags.all) {
|
|
66
|
+
for (const d of await listDevices()) {
|
|
67
|
+
if (d.serial.startsWith('emulator-')) await adbRaw(['-s', d.serial, 'emu', 'kill'], { soft: true });
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
await (await ctx.device()).adb.run(['emu', 'kill'], { soft: true });
|
|
71
|
+
}
|
|
72
|
+
ctx.out('sent kill');
|
|
73
|
+
}
|