@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
package/docs/commands.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Commands
|
|
2
|
+
|
|
3
|
+
`adbax <command> [arguments] [flags]`. Flags go anywhere on the line,
|
|
4
|
+
`--flag value` and `--flag=value` both work, and `--` ends flag parsing.
|
|
5
|
+
The exception is `shell`: everything after it goes to the device as it is, so
|
|
6
|
+
`-s` and the other global flags go before it.
|
|
7
|
+
|
|
8
|
+
Exit status is 0 on success, 2 when something was not there (no match, an
|
|
9
|
+
ambiguous selector, a `wait` or `scroll-to` that timed out) and 1 for any other
|
|
10
|
+
failure. Errors go to stderr as `adbax: <message>`.
|
|
11
|
+
|
|
12
|
+
## Global flags
|
|
13
|
+
|
|
14
|
+
| Flag | |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `-s <serial>` | the device to talk to; otherwise `$ANDROID_SERIAL`, then the pinned device, then the only one online |
|
|
17
|
+
| `--ui` | after an action, print the screen it led to (saves a separate `adbax ui`) |
|
|
18
|
+
| `--settle <ms>` | how long to give the screen after an action before reading it. Default: 150 with the server, which then also waits for events to stop; 700 without |
|
|
19
|
+
| `--nth N` / `--first` | pick one of several matches instead of exiting 2 |
|
|
20
|
+
| `--re` | the selector is a regular expression |
|
|
21
|
+
| `--shot` | `x,y` are pixels of the last screenshot, not of the screen |
|
|
22
|
+
| `--no-server` | plain `adb shell` and `uiautomator dump`, as on devices the server cannot run on |
|
|
23
|
+
|
|
24
|
+
## Selectors
|
|
25
|
+
|
|
26
|
+
Anything that takes a selector accepts:
|
|
27
|
+
|
|
28
|
+
- **text**: matched against text, content-desc, hint and resource-id,
|
|
29
|
+
case-insensitively. Exact beats prefix beats substring; text beats
|
|
30
|
+
content-desc beats ids. A tap prefers clickable nodes, and a match that is not
|
|
31
|
+
clickable itself resolves to its nearest clickable ancestor.
|
|
32
|
+
- **a row number**, `5` or `#5`: row 5 of the last `adbax ui`, `find`, `wait` or
|
|
33
|
+
`scroll-to`, without reading the screen again. Valid until the screen changes.
|
|
34
|
+
- **coordinates**, `540,1200`: taken as they are, or scaled from the last
|
|
35
|
+
screenshot with `--shot`.
|
|
36
|
+
|
|
37
|
+
## Devices
|
|
38
|
+
|
|
39
|
+
| Command | |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `devices` | attached devices; `*` marks the pinned one |
|
|
42
|
+
| `use <serial>` | pin a device for later calls; `use -` clears the pin |
|
|
43
|
+
| `avds` | the emulator's AVDs |
|
|
44
|
+
| `boot <avd> [--cold] [--wipe] [--no-window]` | start an AVD, wait for `sys.boot_completed`, unlock and pin it. Emulator output goes to `~/.cache/adbax/emulator-<avd>.log`; gives up after six minutes |
|
|
45
|
+
| `kill [--all]` | shut down the current emulator, or every running one |
|
|
46
|
+
|
|
47
|
+
## Seeing the screen
|
|
48
|
+
|
|
49
|
+
| Command | |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `ui` | the screen as rows: index, kind, label, short resource-id, centre, size, state. The first line names the top activity, the screen size and the row count |
|
|
52
|
+
| `ui --all` | every node, including labels already folded into a clickable parent |
|
|
53
|
+
| `ui --full` | adds each node's class |
|
|
54
|
+
| `ui --json` | the rows as JSON, for scripts |
|
|
55
|
+
| `ui --raw` | the XML as it came off the device |
|
|
56
|
+
| `ui --windows` | every window, not just the active one: status bar, navigation bar, the keyboard, overlays. Needs the server |
|
|
57
|
+
| `find <text>` | only the nodes matching a selector, with their row numbers |
|
|
58
|
+
| `inspect <text>` | every attribute of the best match, and its ancestor chain |
|
|
59
|
+
| `current` | the focused window and the resumed activity |
|
|
60
|
+
| `screenshot [path]` | writes a PNG (a `.jpg` path writes JPEG) and prints the path. Defaults to `~/.cache/adbax/shot.png` |
|
|
61
|
+
| `screenshot --small` | 540 px wide JPEG. For Claude that is about 690 image tokens, where a full-size 1080x1920 screenshot is about 1,840 |
|
|
62
|
+
| `screenshot --width N` | any width, scaled on the device |
|
|
63
|
+
| `screenshot --quality N` | JPEG quality, 80 by default |
|
|
64
|
+
| `screenshot --grid [step]` | a labelled coordinate grid over the image, in the image's own pixels (default: a sixth of its width). Needs the server |
|
|
65
|
+
| `record [secs] [path]` | a screen recording, 15 s by default, 180 at most |
|
|
66
|
+
|
|
67
|
+
Row kinds: `TAP` clickable, `EDIT` a text field, `CHK[x]`/`CHK[ ]` checkable,
|
|
68
|
+
`SCRL` a scroll container, `TXT` text you can read but not act on. State marks:
|
|
69
|
+
`*focused`, `*selected`, `*password`, `*scrollable`, `!disabled`.
|
|
70
|
+
|
|
71
|
+
## Acting
|
|
72
|
+
|
|
73
|
+
| Command | |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `tap <selector>` | a tap at the centre of the match |
|
|
76
|
+
| `longpress <selector> [--dur ms]` | press and hold, 900 ms by default |
|
|
77
|
+
| `type <text>` | types into the focused field |
|
|
78
|
+
| `type <text> --into <selector>` | taps the field first and waits until it has input focus |
|
|
79
|
+
| `type --clear` | empties the field before typing (or on its own) |
|
|
80
|
+
| `type <text> --set` | replaces the whole field in one step (ACTION_SET_TEXT), no keystrokes. For long values, where typing over the old text can leave part of it behind |
|
|
81
|
+
| `type ... --enter` | presses Enter afterwards |
|
|
82
|
+
| `type ... --no-verify` | skips reading the field back |
|
|
83
|
+
| `key <key>...` | key events by name (`back home enter recents del tab esc up down left right menu search power volup voldown space paste`...), by number, or as `KEYCODE_*` |
|
|
84
|
+
| `key <key> --long` | a long press of the key |
|
|
85
|
+
| `back`, `home`, `recents` | shorthands |
|
|
86
|
+
| `hide-keyboard` | presses Back only if the soft keyboard is showing; a plain `back` would leave the screen when it isn't |
|
|
87
|
+
| `swipe <up\|down\|left\|right>` | a swipe across the screen. "up" is the finger moving up, which scrolls the content down |
|
|
88
|
+
| `swipe <dir> --in <selector>` | the same inside one node, a carousel or a list |
|
|
89
|
+
| `swipe x1,y1,x2,y2 [--dur ms]` | between two points, 300 ms by default |
|
|
90
|
+
| `scroll-to <text> [--max 12] [--dir up\|down] [--tap]` | swipes the largest scroll container until the text shows up, then optionally taps it |
|
|
91
|
+
| `wait <text> [--timeout 15] [--gone]` | blocks until the text appears (or disappears) |
|
|
92
|
+
|
|
93
|
+
`type` sends key events where the virtual keyboard map has the characters and
|
|
94
|
+
sets the field's text through `ACTION_SET_TEXT` where it does not, so `Grüße`
|
|
95
|
+
and `日本` arrive intact with the server. Without it, `input text` drops
|
|
96
|
+
non-ASCII on most builds and adbax warns about it. After typing, `type` reads the
|
|
97
|
+
field back and says so when it holds something else, a sign that an IME
|
|
98
|
+
autocorrected it or that characters went missing.
|
|
99
|
+
|
|
100
|
+
## Apps
|
|
101
|
+
|
|
102
|
+
| Command | |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| `apps [filter] [--system]` | installed packages, third-party only unless `--system` |
|
|
105
|
+
| `launch <package>` | starts the launcher activity and returns once it is drawn |
|
|
106
|
+
| `start <package/.Activity>` | starts one activity |
|
|
107
|
+
| `stop <package>` | force-stop |
|
|
108
|
+
| `clear <package>` | wipe the app's data |
|
|
109
|
+
| `install <apk> [split.apk ...]` | `-r -g -t`: replace, grant runtime permissions, allow test builds. Several files are installed as one split APK |
|
|
110
|
+
| `uninstall <package>` | |
|
|
111
|
+
| `deeplink <url> [package]` | an `ACTION_VIEW` intent |
|
|
112
|
+
| `perm <grant\|revoke> <package> <PERMISSION>...` | `CAMERA` is short for `android.permission.CAMERA` |
|
|
113
|
+
| `perms <package>` | runtime permissions and their state |
|
|
114
|
+
|
|
115
|
+
## Debugging
|
|
116
|
+
|
|
117
|
+
| Command | |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `log [package] [--lines 200] [--grep re] [--level W]` | logcat; with a package, only its current process |
|
|
120
|
+
| `crash` | the crash buffer, which survives the process dying |
|
|
121
|
+
| `clearlog` | empties the main and crash buffers |
|
|
122
|
+
|
|
123
|
+
## Device state
|
|
124
|
+
|
|
125
|
+
| Command | |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| `dark <on\|off>` | night mode |
|
|
128
|
+
| `font <scale>` | system font scale, `1.0` normal, `1.3` large, `2.0` the maximum |
|
|
129
|
+
| `anim <on\|off>` | all three animation scales; off also makes reads after actions settle sooner |
|
|
130
|
+
| `rotate <0\|90\|180\|270\|auto>` | |
|
|
131
|
+
| `demo <on\|off>` | SystemUI demo mode: 10:00, full battery and signal, no notification icons |
|
|
132
|
+
|
|
133
|
+
## The server
|
|
134
|
+
|
|
135
|
+
| Command | |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `server status` | pid, Android version, uptime and the forwarded port |
|
|
138
|
+
| `server start [--idle seconds]` | start it now rather than on first use |
|
|
139
|
+
| `server stop` | stop it and free the accessibility connection |
|
|
140
|
+
| `server restart` | |
|
|
141
|
+
| `server log` | the server's stderr, from `/data/local/tmp/adbax-server.log` |
|
|
142
|
+
|
|
143
|
+
## Raw adb
|
|
144
|
+
|
|
145
|
+
| Command | |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `shell <command>` | runs on the device shell |
|
|
148
|
+
| `pull <remote> [local]`, `push <local> <remote>` | |
|
|
149
|
+
| `version` | |
|
|
150
|
+
|
|
151
|
+
`text`, `press`, `screencap`, `logcat` and `ls` are aliases of `type`,
|
|
152
|
+
`longpress`, `screenshot`, `log` and `apps`.
|
|
153
|
+
|
|
154
|
+
## Environment
|
|
155
|
+
|
|
156
|
+
| Variable | |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| `ANDROID_SERIAL` | the device to use; `-s` and `adbax use` do the same |
|
|
159
|
+
| `ANDROID_HOME` | the SDK, if adb or the emulator is not on the PATH |
|
|
160
|
+
| `ADBAX_SERVER=0` | never start the on-device server |
|
|
161
|
+
| `ADBAX_SERVER_IDLE` | seconds before an unused server exits (600) |
|
|
162
|
+
| `ADBAX_CACHE` | where the device pin and the last dump go (`~/.cache/adbax`) |
|
|
163
|
+
| `ADBAX_TRACE=1` | print every adb call and server request with its duration |
|
package/package.json
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flxapps/adbax",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drive an Android device or emulator through its accessibility tree instead of screenshots and pixel coordinates.",
|
|
5
|
+
"keywords": ["android", "adb", "emulator", "uiautomator", "accessibility", "automation", "testing", "cli", "agent"],
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "Felix Heller",
|
|
8
|
+
"homepage": "https://github.com/flxapps/adbax#readme",
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/flxapps/adbax.git" },
|
|
10
|
+
"bugs": { "url": "https://github.com/flxapps/adbax/issues" },
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": { "adbax": "bin/adbax.js" },
|
|
13
|
+
"files": ["bin/", "src/", "server/adbax-server.jar", "skill/", "docs/", "NOTICE"],
|
|
14
|
+
"engines": { "node": ">=18" },
|
|
15
|
+
"publishConfig": { "access": "public" },
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test",
|
|
18
|
+
"build:server": "sh server/build.sh"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
Binary file
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: android-device
|
|
3
|
+
description: Drive an Android emulator or physical device through its accessibility tree instead of screenshots and pixel guessing. Boot AVDs, read the screen as a labelled node list, tap and type by label, scroll to off-screen elements, wait for state, install APKs, read logcat and crashes, play canvas games through grid screenshots, set dark mode / font scale / demo-mode status bar for store screenshots. Use whenever a task involves an emulator or a connected phone, e.g. "open the app on the emulator", "tap through onboarding", "boot Pixel_7_Pro and check the settings screen", "why does it crash on launch", "take store screenshots".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Driving an Android device
|
|
7
|
+
|
|
8
|
+
`adbax` must be on the PATH (`npm install -g @flxapps/adbax`, or a link to
|
|
9
|
+
`bin/adbax.js` in a checkout). `adbax help` prints the whole surface.
|
|
10
|
+
|
|
11
|
+
## Read the tree, do not read pixels
|
|
12
|
+
|
|
13
|
+
A full-size phone screenshot costs about 1,800 image tokens, has to be read
|
|
14
|
+
like an image and gives coordinates you then guess at. The accessibility tree
|
|
15
|
+
costs a few hundred text tokens and gives exact labels, ids and centres:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
$ adbax ui
|
|
19
|
+
# com.android.settings/.Settings 1080x1920 11 nodes
|
|
20
|
+
[0] TAP "Search settings" id=search_action_bar @540,147 1038x126
|
|
21
|
+
[5] TAP "Network & Internet / Wi‑Fi, mobile, data usage" id=dashboard_tile @540,903 1080x189
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then act on the label, never on the pixel:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
adbax tap "Network & Internet"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The first call starts a small server on the device (about a second). After
|
|
31
|
+
that a read takes 0.2-0.3 s end to end, so reading after every step is cheap.
|
|
32
|
+
If adbax says the server is unavailable it has fallen back to `uiautomator dump`
|
|
33
|
+
at about 2 s per read; budget for that and do not poll `ui` in a loop.
|
|
34
|
+
|
|
35
|
+
## The loop
|
|
36
|
+
|
|
37
|
+
Chain steps in **one** Bash call; each `adbax` is its own process and `&&` costs
|
|
38
|
+
nothing:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
adbax launch com.example.app && adbax wait "Start" && adbax tap "Settings" --ui
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`--ui` on any action prints the screen it led to, after waiting for the
|
|
45
|
+
transition to finish. Use it whenever you need to see the result.
|
|
46
|
+
|
|
47
|
+
| Want | Command |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| see the screen | `adbax ui` (`--all` includes layout nodes, `--full` adds classes, `--windows` adds system bars and the keyboard) |
|
|
50
|
+
| find one thing | `adbax find "Delete"` (cheaper to read than a full dump) |
|
|
51
|
+
| everything about a node | `adbax inspect "Delete"` (attributes plus ancestor chain) |
|
|
52
|
+
| tap | `adbax tap "Sign in"` / `adbax tap 5` / `adbax tap 540,1200` |
|
|
53
|
+
| type | `adbax type you@example.com --into Email --enter` |
|
|
54
|
+
| replace a field's text | `adbax type "new" --into Name --clear` |
|
|
55
|
+
| off-screen element | `adbax scroll-to "Delete account" --tap` |
|
|
56
|
+
| block until ready | `adbax wait "Welcome" --timeout 20` (`--gone` for the inverse) |
|
|
57
|
+
| hardware keys | `adbax key back home enter recents del` |
|
|
58
|
+
|
|
59
|
+
Selectors match **text, content-desc and resource-id**, case-insensitively,
|
|
60
|
+
exact before prefix before substring. A match that is not itself clickable
|
|
61
|
+
resolves to its nearest clickable ancestor, so `adbax tap "mobile, data usage"`
|
|
62
|
+
hits the whole row. `--re` switches to a regex.
|
|
63
|
+
|
|
64
|
+
**Ambiguity exits 2 and lists the candidates** rather than picking one. Narrow
|
|
65
|
+
the query, or pass `--nth N` / `--first`. Treat that list as the answer, not as
|
|
66
|
+
an error to retry around.
|
|
67
|
+
|
|
68
|
+
`adbax tap 5` reuses the last read's row numbers and skips a fresh one. Row
|
|
69
|
+
numbers are only valid until the screen changes.
|
|
70
|
+
|
|
71
|
+
`type` handles umlauts, CJK and emoji when the server runs (it sets the field's
|
|
72
|
+
text for characters the keyboard map lacks and says so). It reads the field back
|
|
73
|
+
afterwards and reports when it holds something other than what was typed.
|
|
74
|
+
|
|
75
|
+
## When the tree comes back empty
|
|
76
|
+
|
|
77
|
+
Compose, Views and Flutter all populate it. What does not:
|
|
78
|
+
|
|
79
|
+
- Games and other canvas or GL surfaces. These draw without nodes.
|
|
80
|
+
- `FLAG_SECURE` windows: payment sheets, some password managers. Screenshots of
|
|
81
|
+
them come back black as well.
|
|
82
|
+
- WebViews that draw to a `<canvas>`.
|
|
83
|
+
|
|
84
|
+
For those, read the screen as an image with a coordinate grid, and tap in the
|
|
85
|
+
image's own pixels:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
adbax screenshot --grid # prints the path of a 540 px JPEG with a labelled grid
|
|
89
|
+
adbax tap 270,480 --shot # 270,480 in that image, scaled to the screen
|
|
90
|
+
adbax swipe 100,800,440,800 --shot --dur 400
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Read the labels off the grid rather than estimating. `--shot` always refers to
|
|
94
|
+
the last screenshot taken on that device, so take a fresh one after the screen
|
|
95
|
+
moves. `adbax screenshot --small` is the same image without the grid.
|
|
96
|
+
|
|
97
|
+
## The server holds the accessibility connection
|
|
98
|
+
|
|
99
|
+
Android allows one UiAutomation client at a time. While the adbax server runs,
|
|
100
|
+
`uiautomator dump`, UiAutomator or screenshot instrumentation tests, Appium and
|
|
101
|
+
Maestro cannot connect. Before running instrumentation tests, `adbax server stop`.
|
|
102
|
+
It also exits on its own after ten minutes without a request.
|
|
103
|
+
|
|
104
|
+
## Device and app management
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
adbax avds # list AVDs
|
|
108
|
+
adbax boot Pixel_7_Pro # boots, waits for sys.boot_completed, pins it
|
|
109
|
+
adbax devices # * marks the pinned one
|
|
110
|
+
adbax use emulator-5554 # pin when several are attached (or -s per call)
|
|
111
|
+
|
|
112
|
+
adbax apps example # installed third-party packages matching "example"
|
|
113
|
+
adbax install app-debug.apk # -r -g -t, so runtime permissions are pre-granted
|
|
114
|
+
adbax install base.apk split_config.arm64_v8a.apk # split APKs in one go
|
|
115
|
+
adbax launch com.example.app # returns once the activity is drawn
|
|
116
|
+
adbax clear com.example.app # wipe data, back to first run
|
|
117
|
+
adbax deeplink "myapp://order/42"
|
|
118
|
+
adbax perm grant com.example.app POST_NOTIFICATIONS
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Debugging
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
adbax clearlog && adbax launch com.example.app && adbax log com.example.app --lines 200 --level W
|
|
125
|
+
adbax crash # the crash buffer, which survives the process dying
|
|
126
|
+
adbax log com.example.app --grep "OkHttp|Retrofit"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`log <pkg>` resolves the current pid, so after the app restarts it shows the
|
|
130
|
+
new process only. Without a package it is the whole buffer.
|
|
131
|
+
|
|
132
|
+
## Screenshots worth keeping
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
adbax demo on # 10:00, full battery, full signal, no notification icons
|
|
136
|
+
adbax dark on
|
|
137
|
+
adbax anim off # also makes reads after actions settle sooner
|
|
138
|
+
adbax font 1.0
|
|
139
|
+
adbax screenshot ~/shots/01.png # full resolution
|
|
140
|
+
adbax demo off
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`adbax record 20 walkthrough.mp4` for a clip.
|
package/src/adb.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { AdbaxError, cachePath, fail, trace } from './util.js';
|
|
7
|
+
|
|
8
|
+
function sdkDir() {
|
|
9
|
+
const env = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
10
|
+
if (env) return env;
|
|
11
|
+
const home = os.homedir();
|
|
12
|
+
const usual = { darwin: 'Library/Android/sdk', win32: 'AppData/Local/Android/Sdk' }[process.platform] || 'Android/Sdk';
|
|
13
|
+
const guesses = [usual, 'Library/Android/sdk', 'Android/Sdk', 'AppData/Local/Android/Sdk'].map((g) => path.join(home, g));
|
|
14
|
+
return guesses.find((g) => fs.existsSync(g)) || guesses[0];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const found = {};
|
|
18
|
+
/** Finds `adb` or `emulator` on PATH, then in the SDK. */
|
|
19
|
+
export function binary(name, sub) {
|
|
20
|
+
if (found[name]) return found[name];
|
|
21
|
+
const exe = process.platform === 'win32' ? `${name}.exe` : name;
|
|
22
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
23
|
+
const p = path.join(dir, exe);
|
|
24
|
+
if (dir && fs.existsSync(p)) return (found[name] = p);
|
|
25
|
+
}
|
|
26
|
+
const p = path.join(sdkDir(), sub, exe);
|
|
27
|
+
if (fs.existsSync(p)) return (found[name] = p);
|
|
28
|
+
fail(`${name} not found on PATH or in ${path.join(sdkDir(), sub)}. Set ANDROID_HOME.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Runs adb with the given argv. Resolves stdout (a string, or a Buffer with `buffer`). */
|
|
32
|
+
export function adbRaw(args, { buffer = false, timeout = 120_000, soft = false } = {}) {
|
|
33
|
+
const started = performance.now();
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
execFile(binary('adb', 'platform-tools'), args,
|
|
36
|
+
{ encoding: buffer ? 'buffer' : 'utf8', maxBuffer: 256 * 1024 * 1024, timeout },
|
|
37
|
+
(err, stdout, stderr) => {
|
|
38
|
+
trace('adb ' + args.join(' ').slice(0, 120), started);
|
|
39
|
+
if (!err) return resolve(stdout);
|
|
40
|
+
if (soft) return resolve(buffer ? Buffer.alloc(0) : '');
|
|
41
|
+
const detail = (stderr && stderr.toString().trim()) || err.message;
|
|
42
|
+
reject(new AdbaxError(`adb ${args.join(' ')}\n ${detail}`));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function listDevices() {
|
|
48
|
+
const out = await adbRaw(['devices', '-l']);
|
|
49
|
+
return out.split('\n').slice(1).filter((l) => l.trim()).map((l) => {
|
|
50
|
+
const m = l.match(/^(\S+)\s+(\S+)(.*)$/);
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
const props = {};
|
|
53
|
+
for (const kv of (m[3] || '').trim().split(/\s+/)) {
|
|
54
|
+
const i = kv.indexOf(':');
|
|
55
|
+
if (i > 0) props[kv.slice(0, i)] = kv.slice(i + 1);
|
|
56
|
+
}
|
|
57
|
+
return { serial: m[1], state: m[2], model: props.model || '', device: props.device || '' };
|
|
58
|
+
}).filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PIN = () => cachePath('device');
|
|
62
|
+
|
|
63
|
+
export function pinnedSerial() {
|
|
64
|
+
try { return fs.readFileSync(PIN(), 'utf8').trim(); } catch { return ''; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function pinSerial(serial) {
|
|
68
|
+
if (serial) fs.writeFileSync(PIN(), serial);
|
|
69
|
+
else fs.rmSync(PIN(), { force: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Picks the device: -s, then $ANDROID_SERIAL, then the pinned one if it is
|
|
74
|
+
* online, then the only one online. Several and none pinned is an error rather
|
|
75
|
+
* than a guess.
|
|
76
|
+
*/
|
|
77
|
+
export async function resolveSerial(flags) {
|
|
78
|
+
if (flags.serial) return flags.serial;
|
|
79
|
+
if (process.env.ANDROID_SERIAL) return process.env.ANDROID_SERIAL;
|
|
80
|
+
const all = await listDevices();
|
|
81
|
+
const online = all.filter((d) => d.state === 'device');
|
|
82
|
+
const pinned = pinnedSerial();
|
|
83
|
+
if (pinned && online.some((d) => d.serial === pinned)) return pinned;
|
|
84
|
+
if (online.length === 1) return online[0].serial;
|
|
85
|
+
if (online.length === 0) {
|
|
86
|
+
const waiting = all.find((d) => d.state === 'unauthorized');
|
|
87
|
+
if (waiting) fail(`${waiting.serial} is unauthorized: accept the USB debugging prompt on the device.`);
|
|
88
|
+
if (all.length) fail(`no device online: ${all.map((d) => `${d.serial} ${d.state}`).join(', ')}.`);
|
|
89
|
+
fail('no device connected. `adbax avds` then `adbax boot <avd>`.');
|
|
90
|
+
}
|
|
91
|
+
fail(`several devices: ${online.map((d) => d.serial).join(', ')}\n` +
|
|
92
|
+
' pick one with `adbax use <serial>` or -s <serial>.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** adb bound to one device. */
|
|
96
|
+
export class Adb {
|
|
97
|
+
constructor(serial) {
|
|
98
|
+
this.serial = serial;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
run(args, opts) {
|
|
102
|
+
return adbRaw(['-s', this.serial, ...args], opts);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `cmd` is one string for the device shell; quote user input with q(). */
|
|
106
|
+
shell(cmd, opts) {
|
|
107
|
+
return this.run(['shell', cmd], opts);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
execOut(cmd, opts) {
|
|
111
|
+
return this.run(['exec-out', cmd], { buffer: true, ...opts });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The emulator binary is only needed for avds/boot, so it is looked up lazily. */
|
|
116
|
+
export const emulatorBinary = () => binary('emulator', 'emulator');
|
|
117
|
+
|
|
118
|
+
export function runEmulatorSync(args) {
|
|
119
|
+
return execFileSync(emulatorBinary(), args, { encoding: 'utf8' });
|
|
120
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/** Flags that take a value: `--timeout 15` and `--timeout=15` both work. */
|
|
4
|
+
const NEEDS_VALUE = new Set(['serial', 'settle', 'nth', 'timeout', 'max', 'dir', 'dur', 'in', 'into',
|
|
5
|
+
'scale', 'width', 'lines', 'grep', 'level', 'idle', 'quality']);
|
|
6
|
+
/** Flags that take a value only when one follows that is not another flag. */
|
|
7
|
+
const OPTIONAL_VALUE = new Set(['grid']);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Splits argv into the command, its positional arguments and its flags.
|
|
11
|
+
* `-s <serial>` is the one short flag, kept for parity with adb. Everything
|
|
12
|
+
* after `shell` belongs to the device command, so `adbax shell logcat -s Tag`
|
|
13
|
+
* keeps its own flags.
|
|
14
|
+
*/
|
|
15
|
+
export function parseArgs(argv) {
|
|
16
|
+
const flags = {};
|
|
17
|
+
const pos = [];
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (pos[0] === 'shell') { pos.push(...argv.slice(i)); break; }
|
|
21
|
+
if (a === '--') { pos.push(...argv.slice(i + 1)); break; }
|
|
22
|
+
if (a.startsWith('--')) {
|
|
23
|
+
const eq = a.indexOf('=');
|
|
24
|
+
if (eq > -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
25
|
+
else if (NEEDS_VALUE.has(a.slice(2)) && i + 1 < argv.length) flags[a.slice(2)] = argv[++i];
|
|
26
|
+
else if (OPTIONAL_VALUE.has(a.slice(2)) && /^\d+$/.test(argv[i + 1] || '')) flags[a.slice(2)] = argv[++i];
|
|
27
|
+
else flags[a.slice(2)] = true;
|
|
28
|
+
} else if (a === '-s' && i + 1 < argv.length) {
|
|
29
|
+
flags.serial = argv[++i];
|
|
30
|
+
} else if (a === '-h') {
|
|
31
|
+
flags.help = true;
|
|
32
|
+
} else {
|
|
33
|
+
pos.push(a);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { cmd: pos.shift(), pos, flags };
|
|
37
|
+
}
|