device-devtools-mcp 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.
Files changed (66) hide show
  1. package/AGENTS.md +327 -0
  2. package/LICENSE +21 -0
  3. package/README.md +491 -0
  4. package/VERSION +1 -0
  5. package/bin/device-devtools-mcp.js +106 -0
  6. package/bin/devicetools +315 -0
  7. package/config.example.json +63 -0
  8. package/integrations/agent-pointer.sh +48 -0
  9. package/integrations/claude/SKILL.md +8 -0
  10. package/integrations/cursor/devicetools.mdc +8 -0
  11. package/integrations/gemini/GEMINI.md +5 -0
  12. package/integrations/mcp/README.md +184 -0
  13. package/integrations/mcp/mcp.json +10 -0
  14. package/integrations/mcp/reference.sh +119 -0
  15. package/integrations/mcp/selftest.sh +158 -0
  16. package/integrations/mcp/server.sh +343 -0
  17. package/package.json +50 -0
  18. package/scripts/android/app.sh +241 -0
  19. package/scripts/android/back.sh +73 -0
  20. package/scripts/android/controls.sh +56 -0
  21. package/scripts/android/devices.sh +77 -0
  22. package/scripts/android/doctor.sh +253 -0
  23. package/scripts/android/lib.sh +699 -0
  24. package/scripts/android/logs.sh +289 -0
  25. package/scripts/android/permission.sh +119 -0
  26. package/scripts/android/settings.sh +63 -0
  27. package/scripts/android/setup.sh +97 -0
  28. package/scripts/android/tree.awk +166 -0
  29. package/scripts/android/tree.sh +127 -0
  30. package/scripts/android/type.sh +191 -0
  31. package/scripts/common/find.sh +95 -0
  32. package/scripts/common/key.sh +65 -0
  33. package/scripts/common/lib.sh +14 -0
  34. package/scripts/common/measure.sh +170 -0
  35. package/scripts/common/open.sh +131 -0
  36. package/scripts/common/screenshot.sh +102 -0
  37. package/scripts/common/scroll.sh +177 -0
  38. package/scripts/common/snapshot.sh +69 -0
  39. package/scripts/common/swipe.sh +171 -0
  40. package/scripts/common/tap.sh +226 -0
  41. package/scripts/common/wait.sh +212 -0
  42. package/scripts/common/waypoint.sh +141 -0
  43. package/scripts/dispatch.sh +21 -0
  44. package/scripts/flow.sh +266 -0
  45. package/scripts/init.sh +101 -0
  46. package/scripts/ios/app.sh +404 -0
  47. package/scripts/ios/back.sh +95 -0
  48. package/scripts/ios/controls.sh +68 -0
  49. package/scripts/ios/devices.sh +80 -0
  50. package/scripts/ios/doctor.sh +386 -0
  51. package/scripts/ios/lib.sh +864 -0
  52. package/scripts/ios/logs.sh +272 -0
  53. package/scripts/ios/permission.sh +108 -0
  54. package/scripts/ios/settings.sh +76 -0
  55. package/scripts/ios/setup.sh +175 -0
  56. package/scripts/ios/tree.sh +128 -0
  57. package/scripts/ios/type.sh +178 -0
  58. package/scripts/lib.sh +1032 -0
  59. package/scripts/links.tsv +44 -0
  60. package/scripts/relink.sh +121 -0
  61. package/scripts/run.sh +415 -0
  62. package/scripts/selftest.sh +1709 -0
  63. package/scripts/snapshot.awk +362 -0
  64. package/scripts/verify-npm-package.js +133 -0
  65. package/tests/fixtures/ios-contacts-list.expected +52 -0
  66. package/tests/fixtures/ios-contacts-list.rows +140 -0
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env bash
2
+ # DeviceTools — Android adapter internals: everything that knows about adb and the
3
+ # UiAutomator2 server.
4
+ #
5
+ # Sourced by the scripts in this directory, never by anything above it. The
6
+ # platform-neutral half lives in ../lib.sh and is sourced from here, so an
7
+ # adapter script sources exactly one file.
8
+ #
9
+ # WHY A SERVER ON THE DEVICE RATHER THAN PLAIN adb
10
+ #
11
+ # `adb shell uiautomator dump` needs nothing installed, and for reading the
12
+ # screen it works. It was rejected on two measurements:
13
+ #
14
+ # reading the tree dump 4.3s server 0.11s
15
+ # typing "Lê" dump — impossible
16
+ #
17
+ # `adb shell input text` rejects every non-ASCII string. Four encodings were
18
+ # tried; each returned "Exception occurred while executing 'text'" while the
19
+ # same call with ASCII returned 0. For an app whose login screen is in
20
+ # Vietnamese that is not a limitation, it is a disqualification.
21
+ #
22
+ # So Android gets the same shape as iOS: an HTTP driver installed on the device,
23
+ # separate from the app under test. It is a separate application, so the rule
24
+ # that no instrumentation code goes inside the app under test still holds.
25
+
26
+ source "$(dirname "${BASH_SOURCE[0]}")/../lib.sh"
27
+
28
+ ua2_base() {
29
+ printf 'http://%s:%s' "$(cfg '.ua2.host' '127.0.0.1')" "$(cfg '.ua2.local_port' '6790')"
30
+ }
31
+
32
+ http_timeout() { printf '%s' "${DT_HTTP_TIMEOUT:-$(cfg '.timeouts.http_seconds' '10')}"; }
33
+
34
+ device_serial() { cfg '.device.serial'; }
35
+
36
+ # adb_dev — adb bound to the configured device, and detached from stdin.
37
+ #
38
+ # Never bare `adb`: with two devices attached, bare adb fails, and with one
39
+ # attached it succeeds against whichever one that is. A test suite that runs on
40
+ # the wrong phone is worse than one that refuses to run.
41
+ #
42
+ # THE `< /dev/null` IS NOT DECORATION. `adb shell` forwards its standard input
43
+ # to the device, so an adb call anywhere inside a pipeline eats the data flowing
44
+ # through that pipeline. It was found here as log lines vanishing: a
45
+ # `$(adb shell date +%z)` used to build an awk argument silently consumed every
46
+ # record awk was about to format, and the command reported success with no
47
+ # output. No adb invocation in this tool wants stdin, so it is closed for all of
48
+ # them rather than remembered at each call site.
49
+ adb_dev() { adb -s "$(device_serial)" "$@" < /dev/null; }
50
+
51
+ # pkg_installed <package> — is exactly this package present on the device?
52
+ #
53
+ # ASK ABOUT ONE PACKAGE. NEVER LIST THEM ALL AND SEARCH THE LIST.
54
+ #
55
+ # `pm list packages` with no argument returns every package — 243 of them on a
56
+ # CI emulator — and that output came back TRUNCATED, differently on two queries
57
+ # seconds apart. One run could not find the UiAutomator2 server; the next found
58
+ # the server but not its test harness; a plain `adb shell pm list packages` run
59
+ # by hand two seconds earlier had listed all three packages involved, and `adb
60
+ # install` had reported Success for both APKs. Nothing was wrong with the
61
+ # device or with the matching. The list was simply not arriving whole.
62
+ #
63
+ # Passing the name filters it on the device, so the reply is one or two lines
64
+ # and there is nothing to truncate. The filter is a substring match, so the
65
+ # exact comparison still has to happen here — `…server` must not answer for
66
+ # `…server.test`.
67
+ pkg_installed() {
68
+ [ -n "${1:-}" ] || return 1
69
+ adb_dev shell pm list packages "$1" 2>/dev/null | tr -d '\r' | grep -qx "package:$1"
70
+ }
71
+
72
+ # android_kind — "device" or "emulator", resolving "auto" by asking the device.
73
+ ANDROID_KIND=""
74
+ android_kind() {
75
+ [ -n "$ANDROID_KIND" ] && { printf '%s' "$ANDROID_KIND"; return 0; }
76
+ local k
77
+ k="$(target_kind)"
78
+ case "$k" in
79
+ device|emulator) ANDROID_KIND="$k" ;;
80
+ auto)
81
+ # ro.kernel.qemu is set on emulators and absent on hardware; the serial
82
+ # prefix agrees, and checking both means a renamed serial cannot mislead.
83
+ if [ "$(adb_dev shell getprop ro.kernel.qemu 2>/dev/null | tr -d '\r')" = "1" ] \
84
+ || case "$(device_serial)" in emulator-*) true ;; *) false ;; esac; then
85
+ ANDROID_KIND=emulator
86
+ else
87
+ ANDROID_KIND=device
88
+ fi ;;
89
+ *) die "device.kind must be auto, device or emulator — got '$k'" 2 ;;
90
+ esac
91
+ printf '%s' "$ANDROID_KIND"
92
+ }
93
+
94
+ # ua2_fail <curl-exit> <url> — one line saying what actually went wrong.
95
+ #
96
+ # Unreachable and timed-out call for different reactions, and collapsing them
97
+ # sends the caller to restart a server that is in fact running.
98
+ ua2_fail() {
99
+ case "$1" in
100
+ 28) die "UiAutomator2 did not answer within $(http_timeout)s at $2 — it is running but stuck on this request" 3 ;;
101
+ 7) die "UiAutomator2 unreachable at $2 (connection refused) — run $(as_cmd doctor)" 3 ;;
102
+ *) die "UiAutomator2 request failed at $2 (curl $1) — run $(as_cmd doctor)" 3 ;;
103
+ esac
104
+ }
105
+
106
+ ua2_get() {
107
+ local body rc=0
108
+ body="$(curl -sS -m "$(http_timeout)" "$(ua2_base)$1" 2>/dev/null)" || rc=$?
109
+ [ "$rc" -eq 0 ] || ua2_fail "$rc" "$(ua2_base)$1"
110
+ printf '%s' "$body"
111
+ }
112
+
113
+ ua2_post() {
114
+ local body rc=0
115
+ body="$(curl -sS -m "$(http_timeout)" -X POST -H 'Content-Type: application/json' \
116
+ -d "${2:-\{\}}" "$(ua2_base)$1" 2>/dev/null)" || rc=$?
117
+ [ "$rc" -eq 0 ] || ua2_fail "$rc" "$(ua2_base)$1"
118
+ printf '%s' "$body"
119
+ }
120
+
121
+ # ua2_error <response> — the error as one short line, or empty on success.
122
+ #
123
+ # UiAutomator2 answers a failure with a full Java stack trace. One "no such
124
+ # element" measured over 2,000 tokens of frames from io.appium.uiautomator2,
125
+ # androidx.test and java.lang, none of which say anything about the app. Passing
126
+ # that through would put a stack trace in the caller's context on every miss, so
127
+ # only the error name and message survive, and the message is capped.
128
+ ua2_error() {
129
+ printf '%s' "$1" | jq -r '
130
+ if (.value | type) == "object" and (.value.error // "") != "" then
131
+ (.value.error // "error")
132
+ + (if (.value.message // "") != ""
133
+ then ": " + ((.value.message | gsub("[\n\r\t]+"; " "))[:160])
134
+ else "" end)
135
+ else empty end' 2>/dev/null || true
136
+ }
137
+
138
+ # --- sessions ----------------------------------------------------------------
139
+ #
140
+ # Every command that acts on the UI needs a session. Creating one per call costs
141
+ # a round trip on every action, so the id is cached on disk and reused. The
142
+ # cache is a hint and never truth: sessions die when the instrumentation
143
+ # restarts, so a stale id is detected from the response and replaced once.
144
+
145
+ session_file() { printf '%s/session.id' "$(state_dir)"; }
146
+
147
+ ua2_new_session() {
148
+ local resp sid err
149
+ resp="$(ua2_post '/session' '{"capabilities":{"alwaysMatch":{},"firstMatch":[{}]}}')"
150
+ err="$(ua2_error "$resp")"
151
+ [ -z "$err" ] || die "UiAutomator2 refused a new session — $err" 3
152
+ sid="$(printf '%s' "$resp" | jq -r '.value.sessionId // .sessionId // empty' 2>/dev/null)"
153
+ [ -n "$sid" ] || die "UiAutomator2 returned no session id — run $(as_cmd doctor)" 3
154
+ printf '%s' "$sid" > "$(session_file)"
155
+ printf '%s' "$sid"
156
+ }
157
+
158
+ ua2_drop_session() { rm -f "$(session_file)" 2>/dev/null || true; }
159
+
160
+ ua2_session() {
161
+ local f sid
162
+ f="$(session_file)"
163
+ if [ -f "$f" ]; then
164
+ sid="$(cat "$f" 2>/dev/null || true)"
165
+ [ -n "$sid" ] && { printf '%s' "$sid"; return 0; }
166
+ fi
167
+ ua2_new_session
168
+ }
169
+
170
+ # session_get / session_post — a request under the current session, retried once
171
+ # against a fresh session when the cached one turns out to be dead.
172
+ session_get() {
173
+ local sid resp err
174
+ sid="$(ua2_session)"
175
+ resp="$(ua2_get "/session/$sid$1")"
176
+ err="$(ua2_error "$resp")"
177
+ case "$err" in
178
+ *"invalid session"*|*"session not"*|*"no such session"*|*"terminated"*)
179
+ ua2_drop_session
180
+ sid="$(ua2_new_session)"
181
+ resp="$(ua2_get "/session/$sid$1")" ;;
182
+ esac
183
+ printf '%s' "$resp"
184
+ }
185
+
186
+ session_post() {
187
+ local sid resp err
188
+ sid="$(ua2_session)"
189
+ resp="$(ua2_post "/session/$sid$1" "${2:-}")"
190
+ err="$(ua2_error "$resp")"
191
+ case "$err" in
192
+ *"invalid session"*|*"session not"*|*"no such session"*|*"terminated"*)
193
+ ua2_drop_session
194
+ sid="$(ua2_new_session)"
195
+ resp="$(ua2_post "/session/$sid$1" "${2:-}")" ;;
196
+ esac
197
+ printf '%s' "$resp"
198
+ }
199
+
200
+ # session_error — kept as a name shared with the iOS adapter so the verb scripts
201
+ # read the same on both platforms.
202
+ session_error() { ua2_error "$1"; }
203
+
204
+ # --- reading the screen ------------------------------------------------------
205
+ #
206
+ # TREE_AWK converts UiAutomator's XML into the line table every verb consumes.
207
+ # See tree.awk for why this is not a call to yq.
208
+ TREE_AWK="$(dirname "${BASH_SOURCE[0]}")/tree.awk"
209
+
210
+ # fetch_source — the parsed line table, including the leading WINDOW line.
211
+ #
212
+ # Internal only: nothing may print this. It is already far smaller than the XML
213
+ # it came from, but a full screen is still hundreds of lines.
214
+ # AN EMPTY TREE IS A MISSING ANSWER, NOT AN ANSWER. ASK AGAIN.
215
+ #
216
+ # UiAutomator dumps the hierarchy of whatever is on screen at that instant, and
217
+ # during a window transition that is briefly nothing at all — a valid XML
218
+ # document describing no nodes. Treating it as "the screen has no controls"
219
+ # turns a race into an assertion failure against a screen that is fine a
220
+ # hundred milliseconds later.
221
+ #
222
+ # Measured on a GitHub Actions emulator: `assert visible
223
+ # id:open_search_view_edit_text` failed here on the search screen opening, on a
224
+ # suite that passes every time on a developer's machine. A slower device does
225
+ # not have different windows, it has longer transitions.
226
+ #
227
+ # Three attempts, a short pause between them. It stays a hard failure if the
228
+ # tree is genuinely empty, so a driver that has actually stopped answering is
229
+ # still reported — this buys a transition time to finish, not silence.
230
+ FETCH_SOURCE_TRIES=3
231
+
232
+ fetch_source() {
233
+ local resp xml table i
234
+ i=1
235
+ while [ "$i" -le "$FETCH_SOURCE_TRIES" ]; do
236
+ resp="$(session_get '/source?format=xml')"
237
+ xml="$(printf '%s' "$resp" | jq -r '.value // empty' 2>/dev/null)"
238
+ if [ -n "$xml" ]; then
239
+ table="$(printf '%s' "$xml" | awk -f "$TREE_AWK")"
240
+ [ -n "$table" ] && { printf '%s' "$table"; return 0; }
241
+ fi
242
+ [ "$i" -lt "$FETCH_SOURCE_TRIES" ] && sleep 0.4
243
+ i=$((i + 1))
244
+ done
245
+ [ -n "$xml" ] || die "UiAutomator2 returned no tree — run $(as_cmd doctor)" 3
246
+ die "the screen hierarchy parsed to nothing $FETCH_SOURCE_TRIES times — run $(as_cmd doctor)" 3
247
+ }
248
+
249
+ # try_fetch_source — like fetch_source, but returns 1 instead of dying.
250
+ #
251
+ # For advisory work that must not kill an operation which already succeeded: a
252
+ # screenshot that was written is still a screenshot, even if the check that
253
+ # annotates it could not run.
254
+ try_fetch_source() {
255
+ local sid resp xml table rc=0
256
+ sid="$(cat "$(session_file)" 2>/dev/null || true)"
257
+ [ -n "$sid" ] || return 1
258
+ resp="$(curl -sS -m "$(http_timeout)" "$(ua2_base)/session/$sid/source?format=xml" 2>/dev/null)" || rc=$?
259
+ [ "$rc" -eq 0 ] || return 1
260
+ xml="$(printf '%s' "$resp" | jq -r '.value // empty' 2>/dev/null)" || return 1
261
+ [ -n "$xml" ] || return 1
262
+ table="$(printf '%s' "$xml" | awk -f "$TREE_AWK")" || return 1
263
+ [ -n "$table" ] || return 1
264
+ printf '%s' "$table"
265
+ }
266
+
267
+ # window_size [source] — "<w> <h>", taken from the hierarchy root.
268
+ #
269
+ # iOS pays a request for this. Android does not: UiAutomator puts the window
270
+ # size on the root node of the tree, so a caller that already holds a source can
271
+ # pass it and pay nothing. The zero-argument form exists because that is the
272
+ # shape the iOS adapter exposes, and a verb that works on both platforms must
273
+ # not have to know which one it is talking to.
274
+ window_size() {
275
+ local src="${1:-}"
276
+ [ -n "$src" ] || src="$(fetch_source)"
277
+ printf '%s' "$src" | awk -F'|' '$1 == "WINDOW" { print $2, $3; exit }'
278
+ }
279
+
280
+ # device_locale — the language and region the screen is currently rendered in,
281
+ # normalised to en-US form.
282
+ #
283
+ # Two properties, in this order: persist.sys.locale is what the user last chose
284
+ # in Settings, ro.product.locale is what the image shipped with. An emulator
285
+ # nobody has touched has only the second — measured, not assumed.
286
+ #
287
+ # Best-effort by contract. Every caller treats "" as "unknown" and carries on,
288
+ # because provenance must never be the thing that fails a step.
289
+ device_locale() {
290
+ local l
291
+ l="$(adb_dev shell getprop persist.sys.locale 2>/dev/null | tr -d '\r' || true)"
292
+ [ -n "$l" ] || l="$(adb_dev shell getprop ro.product.locale 2>/dev/null | tr -d '\r' || true)"
293
+ printf '%s' "${l//_/-}"
294
+ }
295
+
296
+ # --- gestures ----------------------------------------------------------------
297
+ #
298
+ # Three primitives. Everything a verb does to the screen goes through one of
299
+ # them, which is what keeps tap.sh, swipe.sh, scroll.sh and back.sh free of any
300
+ # knowledge that UiAutomator2 exists.
301
+
302
+ # drv_tap <x> <y>
303
+ drv_tap() {
304
+ local resp err
305
+ resp="$(session_post '/actions' "$(printf '{"actions":[{"type":"pointer","id":"finger1","parameters":{"pointerType":"touch"},"actions":[{"type":"pointerMove","duration":0,"x":%s,"y":%s},{"type":"pointerDown","button":0},{"type":"pause","duration":80},{"type":"pointerUp","button":0}]}]}' "$1" "$2")")"
306
+ err="$(session_error "$resp")"
307
+ [ -z "$err" ] && return 0
308
+ printf '%s' "$err"
309
+ return 1
310
+ }
311
+
312
+ # drv_drag <from-x> <from-y> <to-x> <to-y> <hold-ms> [travel-ms]
313
+ #
314
+ # The hold is the press before the drag begins; a short hold reads as a flick
315
+ # and scrolls with momentum, a long one reads as a deliberate drag and does not.
316
+ # The travel time is separate and is what actually decides how far a flick
317
+ # carries, so both are exposed rather than conflated.
318
+ drv_drag() {
319
+ local resp err travel="${6:-300}"
320
+ resp="$(session_post '/actions' "$(printf '{"actions":[{"type":"pointer","id":"finger1","parameters":{"pointerType":"touch"},"actions":[{"type":"pointerMove","duration":0,"x":%s,"y":%s},{"type":"pointerDown","button":0},{"type":"pause","duration":%s},{"type":"pointerMove","duration":%s,"x":%s,"y":%s},{"type":"pointerUp","button":0}]}]}' \
321
+ "$1" "$2" "$5" "$travel" "$3" "$4")")"
322
+ err="$(session_error "$resp")"
323
+ [ -z "$err" ] && return 0
324
+ printf '%s' "$err"
325
+ return 1
326
+ }
327
+
328
+ # drv_open_url <url> — hand a URL to the system to route.
329
+ #
330
+ # The URL is single-quoted for the shell that runs on the device, not just for
331
+ # the one running here. A link with a query string reaches `am` as several words
332
+ # otherwise, and `&` would background half of it — the sort of thing that works
333
+ # for every URL in the examples and breaks on the first real deep link.
334
+ drv_open_url() {
335
+ local out
336
+ out="$(adb_dev shell "am start -a android.intent.action.VIEW -d '$1'" 2>&1 | tr -d '\r' || true)"
337
+ case "$out" in
338
+ *Error*|*error*|*Exception*)
339
+ printf '%s' "$(printf '%s' "$out" | grep -m1 -i 'error\|exception')"
340
+ return 1 ;;
341
+ esac
342
+ return 0
343
+ }
344
+
345
+ # drv_foreground_app — the package now in front, or empty.
346
+ drv_foreground_app() {
347
+ local line
348
+ line="$(adb_dev shell dumpsys window 2>/dev/null | tr -d '\r' | awk '/mCurrentFocus/ { print; exit }')"
349
+ printf '%s' "$line" | sed -n 's/.*[ {]\([a-zA-Z0-9_.]*\)\/[a-zA-Z0-9_.]*}.*/\1/p'
350
+ }
351
+
352
+ # drv_fingerprint <source> — a number that changes when the screen changes.
353
+ #
354
+ # Deliberately excludes every element's text, and is sorted. Measured on an idle
355
+ # screen, a fingerprint that included text changed between consecutive samples
356
+ # with nothing touching the device, because clocks and carousels update
357
+ # themselves. Structure — types, identifiers and labels — was identical across
358
+ # the same samples. Including text would report every gesture as a success.
359
+ drv_fingerprint() {
360
+ printf '%s' "$1" | awk -F'|' '
361
+ $1 == "WINDOW" || $1 == "" { next }
362
+ $10 != 1 { next }
363
+ { print $2 $3 $4 }' | sort | cksum
364
+ }
365
+
366
+ # drv_fingerprint_pos <source> — as above, but including each element's y.
367
+ #
368
+ # For scrolling, where the structure is unchanged and only the position moves.
369
+ drv_fingerprint_pos() {
370
+ printf '%s' "$1" | awk -F'|' '
371
+ $1 == "WINDOW" || $1 == "" { next }
372
+ $10 != 1 { next }
373
+ { print $2 $3 $4 $7 }' | sort | cksum
374
+ }
375
+
376
+ # tree_rows <source> — the tree in the one shape both platforms agree on:
377
+ #
378
+ # depth|type|id|label|value|x|y|w|h
379
+ #
380
+ # Visible, non-degenerate elements only. This is what the resilient resolver in
381
+ # ../lib.sh works on, and it is the reason that resolver can be written once:
382
+ # the scoring never learns which platform produced the rows.
383
+ tree_rows() {
384
+ printf '%s' "$1" | awk -F'|' '
385
+ $1 == "WINDOW" || $1 == "" { next }
386
+ $10 != 1 || $8 <= 0 || $9 <= 0 { next }
387
+ { printf "%s|%s|%s|%s|%s|%s|%s|%s|%s\n", $1, $2, $3, $4, $5, $6, $7, $8, $9 }'
388
+ }
389
+
390
+ # --- selectors ---------------------------------------------------------------
391
+ #
392
+ # strip_id_prefix — resource ids arrive fully qualified as
393
+ # "com.example.app:id/login_button" and the table stores only the last part,
394
+ # because the package is identical for every element of the app under test. A
395
+ # selector written either way must work, so the selector is reduced the same
396
+ # way before comparison rather than the table being expanded.
397
+ strip_id_prefix() { printf '%s' "${1##*:id/}"; }
398
+
399
+ # match_elements <source> <kind> <value>
400
+ # One line per exact, visible, non-degenerate match:
401
+ # x|y|w|h|type|id|label|text
402
+ #
403
+ # The columns and their order are identical to the iOS adapter's, so every verb
404
+ # above this line is written once.
405
+ #
406
+ # Matching is exact rather than substring on purpose. A screen measured here had
407
+ # five elements whose id was "title"; a substring match that quietly takes the
408
+ # first candidate taps whichever one document order happens to put first, which
409
+ # is the worst available failure mode because it mostly works.
410
+ # holding_elements <source> <text> — the control that contains that text.
411
+ #
412
+ # Android publishes `clickable`, so the container test is exact here rather than
413
+ # inferred from the type. The anchor ignores visibility for the same reason as
414
+ # on iOS: a caption drawn inside a control is still a caption.
415
+ holding_elements() {
416
+ printf '%s' "$1" | awk -F'|' -v val="$2" '
417
+ $1 == "WINDOW" || $1 == "" { next }
418
+ {
419
+ n++
420
+ X[n] = $6; Y[n] = $7; W[n] = $8; H[n] = $9
421
+ T[n] = $2; ID[n] = $3; LB[n] = $4; V[n] = $5
422
+ VIS[n] = $10; CLK[n] = $13
423
+ if (($4 == val || $5 == val) && $8 > 0 && $9 > 0) A[++an] = n
424
+ }
425
+ END {
426
+ for (i = 1; i <= an; i++) {
427
+ a = A[i]; best = 0; bestArea = 0
428
+ for (j = 1; j <= n; j++) {
429
+ if (VIS[j] != 1 || CLK[j] != 1 || W[j] <= 0 || H[j] <= 0) continue
430
+ if (X[j] <= X[a] && Y[j] <= Y[a] \
431
+ && X[j] + W[j] >= X[a] + W[a] && Y[j] + H[j] >= Y[a] + H[a]) {
432
+ area = W[j] * H[j]
433
+ if (best == 0 || area < bestArea) { best = j; bestArea = area }
434
+ }
435
+ }
436
+ if (best) {
437
+ key = X[best] "|" Y[best] "|" W[best] "|" H[best]
438
+ if (!(key in seen)) {
439
+ seen[key] = 1
440
+ printf "%s|%s|%s|%s|%s|%s|%s|%s\n",
441
+ X[best], Y[best], W[best], H[best], T[best], ID[best], LB[best], V[best]
442
+ }
443
+ }
444
+ }
445
+ }'
446
+ }
447
+
448
+ # keyboard_blocks <source> <x> <y> <w> <h> — "x y w h" of the keyboard when it
449
+ # stands between the caller and that element, nothing when it does not.
450
+ #
451
+ # The source argument is unused, and no membership test is needed: uiautomator
452
+ # dumps the application's window, the input method is a window of its own, so
453
+ # nothing this adapter can resolve is ever a key. It costs one adb round trip,
454
+ # measured at 70ms against the emulator this was built on, which is why the
455
+ # caller asks only when it is about to touch the screen.
456
+ #
457
+ # The touchable region is the right rectangle, not the frame — the IME window's
458
+ # frame spans the whole display while only its lower part accepts touches. When
459
+ # the keyboard is down the window is not among the visible ones and this prints
460
+ # nothing. A region split into several rectangles reports its first, so a split
461
+ # keyboard is under-reported rather than over-reported, which is the safe
462
+ # direction for a check that refuses to act.
463
+ keyboard_blocks() {
464
+ local kb cx cy
465
+ kb="$(keyboard_region)"
466
+ [ -n "$kb" ] || return 0
467
+ cx=$(( $2 + $4 / 2 ))
468
+ cy=$(( $3 + $5 / 2 ))
469
+ printf '%s\n' "$kb" | awk -v cx="$cx" -v cy="$cy" '
470
+ cx >= $1 && cx <= $3 && cy >= $2 && cy <= $4 { print $1, $2, $3 - $1, $4 - $2 }'
471
+ }
472
+
473
+ # drv_keyboard_dismiss — ask the keyboard to go away. BACK rather than ESCAPE:
474
+ # while the input method is up, BACK is consumed by it and closes it without
475
+ # navigating, which is what a person does and what every keyboard supports.
476
+ drv_keyboard_dismiss() {
477
+ adb_dev shell input keyevent 4 >/dev/null 2>&1
478
+ }
479
+
480
+ # keyboard_region — "left top right bottom" of the input method's touchable
481
+ # region, or nothing when the keyboard is down.
482
+ keyboard_region() {
483
+ adb_dev shell dumpsys window InputMethod 2>/dev/null \
484
+ | sed -n 's/.*InputMethod, frame=\[[^]]*\], touchableRegion=SkRegion((\([0-9]*\),\([0-9]*\),\([0-9]*\),\([0-9]*\))).*/\1 \2 \3 \4/p' \
485
+ | head -1
486
+ }
487
+
488
+ # keyboard_rect <source> — "x y w h", or nothing when it is down. The source is
489
+ # unused; see keyboard_blocks.
490
+ keyboard_rect() {
491
+ keyboard_region | awk '{ print $1, $2, $3 - $1, $4 - $2 }'
492
+ }
493
+
494
+ # unhittable_twin <source> <kind> <value> <x> <y> <w> <h> — the control this
495
+ # selector really meant, when the caller is about to settle for a label instead.
496
+ # See the iOS adapter for the failure this exists for; here `clickable` answers
497
+ # exactly what type has to be inferred from there.
498
+ unhittable_twin() {
499
+ local val="$3"
500
+ [ "$2" = id ] && val="$(strip_id_prefix "$3")"
501
+ printf '%s' "$1" | awk -F'|' -v kind="$2" -v val="$val" \
502
+ -v rx="$4" -v ry="$5" -v rw="$6" -v rh="$7" '
503
+ $1 == "WINDOW" || $1 == "" { next }
504
+ $8 <= 0 || $9 <= 0 { next }
505
+ {
506
+ if (kind == "id") { named = ($3 == val) }
507
+ else if (kind == "kind") { named = ($2 == val) }
508
+ else if (kind == "text") { named = ($5 == val) }
509
+ else { named = ($4 == val) }
510
+ if (!named) next
511
+ if ($6 == rx && $7 == ry && $8 == rw && $9 == rh) { if ($13 == 1) self_ok = 1; next }
512
+ if ($10 != 1 && $13 == 1 && !found) {
513
+ found = 1; desc = sprintf("%s at %s,%s,%s,%s", $2, $6, $7, $8, $9)
514
+ }
515
+ }
516
+ END { if (!self_ok && found) print desc }'
517
+ }
518
+
519
+ # named_anywhere <source> <kind> <value> — every element the selector names, with
520
+ # the visibility filter off:
521
+ #
522
+ # x|y|w|h|type|elements-sharing-that-exact-rect|visible
523
+ #
524
+ # Reached only after match_elements has already come back empty, so anything
525
+ # here is either invisible or degenerate — which is precisely the distinction
526
+ # `nothing on screen contains it` was failing to draw. See invisible_note.
527
+ named_anywhere() {
528
+ local val="$3"
529
+ [ "$2" = id ] && val="$(strip_id_prefix "$3")"
530
+ printf '%s' "$1" | awk -F'|' -v kind="$2" -v val="$val" '
531
+ $1 == "WINDOW" || $1 == "" { next }
532
+ { rows[NR] = $0; key = $6 "," $7 "," $8 "," $9; same[key]++ }
533
+ END {
534
+ for (i = 1; i <= NR; i++) {
535
+ n = split(rows[i], f, "|")
536
+ if (n < 10) continue
537
+ if (kind == "id") named = (f[3] == val)
538
+ else if (kind == "kind") named = (f[2] == val)
539
+ else if (kind == "text") named = (f[5] == val)
540
+ else if (kind == "any") named = (f[3] == val || f[4] == val || f[5] == val)
541
+ else named = (f[4] == val)
542
+ if (!named) continue
543
+ key = f[6] "," f[7] "," f[8] "," f[9]
544
+ printf "%s|%s|%s|%s|%s|%s|%s\n", f[6], f[7], f[8], f[9], f[2], same[key], (f[10] == 1 ? 1 : 0)
545
+ }
546
+ }'
547
+ }
548
+
549
+ # text_anywhere <source> <text> — does that exact text exist at all, visible or
550
+ # not? Used only to tell a `holding:` failure apart from a missing screen.
551
+ text_anywhere() {
552
+ printf '%s' "$1" | awk -F'|' -v val="$2" '
553
+ $1 == "WINDOW" || $1 == "" { next }
554
+ ($4 == val || $5 == val) { printf "%s|%s|%s|%s|%s\n", $6, $7, $8, $9, $2 }'
555
+ }
556
+
557
+ match_elements() {
558
+ local val="$3"
559
+ if [ "$2" = holding ]; then holding_elements "$1" "$3"; return; fi
560
+ [ "$2" = id ] && val="$(strip_id_prefix "$3")"
561
+ printf '%s' "$1" | awk -F'|' -v kind="$2" -v val="$val" '
562
+ $1 == "WINDOW" || $1 == "" { next }
563
+ $10 != 1 || $8 <= 0 || $9 <= 0 { next }
564
+ {
565
+ if (kind == "id") { ok = ($3 == val) }
566
+ else if (kind == "kind") { ok = ($2 == val) }
567
+ else if (kind == "text") { ok = ($5 == val) }
568
+ else { ok = ($4 == val) }
569
+ if (ok) printf "%s|%s|%s|%s|%s|%s|%s|%s\n", $6, $7, $8, $9, $2, $3, $4, $5
570
+ }'
571
+ }
572
+
573
+ # kinds_on_screen <source> — the element types present, commonest first, with
574
+ # counts. See the iOS adapter: candidate_elements searches identifiers, labels
575
+ # and values, so for a `kind:` that matched nothing it has nothing to say.
576
+ kinds_on_screen() {
577
+ printf '%s' "$1" | awk -F'|' '
578
+ $1 == "WINDOW" || $1 == "" { next }
579
+ $10 != 1 || $8 <= 0 || $9 <= 0 { next }
580
+ { n[$2]++ }
581
+ END { for (k in n) printf "%s x%s\n", k, n[k] }' \
582
+ | sort -t'x' -k2 -rn | take_lines 8 | join_lines ', '
583
+ }
584
+
585
+ # candidate_elements <source> <value> [limit]
586
+ # Near misses, for the message printed when an exact match fails. Being told
587
+ # what is actually on screen is what lets a caller recover without a screenshot.
588
+ # candidate_elements <source> <value> [n] — what is on screen that *contains*
589
+ # the value, for the message printed when nothing matched it exactly.
590
+ #
591
+ # A candidate that some field matches EXACTLY is named with the selector that
592
+ # would reach it. Reported from a real session: `tap text:8` was refused and the
593
+ # suggestion list offered `Key "8"` — which is exactly the thing being asked
594
+ # for, matching on its label rather than its value. Listing the answer without
595
+ # saying how to spell it is a riddle, not a hint.
596
+ candidate_elements() {
597
+ printf '%s' "$1" | awk -F'|' -v val="$2" '
598
+ function lower(s) { return tolower(s) }
599
+ $1 == "WINDOW" || $1 == "" { next }
600
+ $10 != 1 || $8 <= 0 || $9 <= 0 { next }
601
+ ($3 == "" && $4 == "" && $5 == "") { next }
602
+ {
603
+ hay = lower($3 " " $4 " " $5)
604
+ if (index(hay, lower(val)) == 0) next
605
+ out = $2
606
+ if ($3 != "") out = out " #" $3
607
+ if ($4 != "") out = out " \"" $4 "\""
608
+ if ($5 != "" && $5 != $4) out = out " =" $5
609
+ if ($3 == val) out = out " → try id:" val
610
+ else if ($4 == val) out = out " → try label:" val
611
+ else if ($5 == val) out = out " → try text:" val
612
+ print out
613
+ }' | head -n "${3:-5}"
614
+ }
615
+
616
+ # --- the normalised screen -----------------------------------------------------
617
+ #
618
+ # snapshot_rows — see the iOS adapter for the format, which is the point: the
619
+ # two produce the same twelve fields and scripts/snapshot.awk cannot tell which
620
+ # driver was on the other end.
621
+ #
622
+ # This is a re-shaping rather than a second fetch. tree.awk already emits
623
+ # fourteen columns — depth|class|id|label|text|x|y|w|h|visible|enabled|password|
624
+ # clickable|hint — so the work here is renumbering, deriving the parent and
625
+ # folding three booleans into the flags field.
626
+ #
627
+ # NOT YET RUN AGAINST A DEVICE. There was no Android phone attached when this
628
+ # was written, and docs/RELEASE.md says so. The iOS side is proven; this is not,
629
+ # and a reader should treat it as a claim rather than a fact until it is.
630
+ snapshot_rows() {
631
+ local src w h orient
632
+ src="$(fetch_source)"
633
+
634
+ read -r w h <<< "$(window_size "$src")"
635
+ w="${w:-0}"; h="${h:-0}"
636
+ if [ "$w" -gt "$h" ] 2>/dev/null; then orient=landscape; else orient=portrait; fi
637
+ printf 'WINDOW|%s|%s|%s\n' "$w" "$h" "$orient"
638
+
639
+ printf '%s\n' "$src" | awk -F'|' -v OFS='|' '
640
+ $1 == "WINDOW" || $1 == "" { next }
641
+ {
642
+ n++
643
+ depth = $1 + 0
644
+ stack[depth] = n
645
+ parent = (depth == 0) ? 0 : stack[depth - 1] + 0
646
+
647
+ # Android publishes `clickable` on the element, so "actionable" here is a
648
+ # fact rather than the type-based inference the iOS adapter has to make.
649
+ flags = ""
650
+ if ($10 == 1) flags = flags (flags == "" ? "" : ",") "visible"
651
+ if ($11 == 1) flags = flags (flags == "" ? "" : ",") "enabled"
652
+ if ($13 == 1) flags = flags (flags == "" ? "" : ",") "actionable"
653
+
654
+ print n, depth, parent, $2, $3, $4, $5, $6, $7, $8, $9, flags
655
+ }'
656
+ }
657
+
658
+ # content_digest <source> — see the iOS adapter. Rows here, not JSON:
659
+ # depth|class|id|label|text|x|y|w|h|visible|enabled|password|clickable|hint
660
+ content_digest() {
661
+ printf '%s' "$1" | awk -F'|' '
662
+ $1 == "WINDOW" || $1 == "" { next }
663
+ $10 != 1 { next }
664
+ { print $2 "\037" $4 "\037" $5 }' | cksum | awk '{ print $1 }'
665
+ }
666
+
667
+ # capture_png <path> — see the iOS adapter.
668
+ #
669
+ # `exec-out` rather than `shell`: adb's shell channel translates line endings,
670
+ # which corrupts a PNG in a way that is invisible until something tries to
671
+ # decode it.
672
+ capture_png() {
673
+ local out="$1"
674
+ adb_dev exec-out screencap -p > "$out" 2>/dev/null || return 3
675
+ [ -s "$out" ] || { rm -f "$out"; return 3; }
676
+ [ "$(head -c 8 "$out" | od -An -tx1 | tr -d ' \n')" = "89504e470d0a1a0a" ] \
677
+ || { rm -f "$out"; return 3; }
678
+ return 0
679
+ }
680
+
681
+ # logs_since <unix-seconds> — see the iOS adapter for the two rules this obeys:
682
+ # it never starts a collector, and it never fails.
683
+ #
684
+ # The app filter is the package name, which Android does publish and which
685
+ # logcat lines carry, so this one is a match rather than the heuristic the iOS
686
+ # side has to make do with.
687
+ logs_since() {
688
+ local since="$1" f pkg out
689
+ f="$(state_dir)/logcat.log"
690
+ [ -s "$f" ] || return 0
691
+ out="$(awk -F'\t' -v c="$since" '$1 >= c { sub(/^[0-9]+\t/, ""); print }' "$f" 2>/dev/null || true)"
692
+ [ -n "$out" ] || return 0
693
+ pkg="$(cfg '.app.package' '')"
694
+ if [ -n "$pkg" ]; then
695
+ out="$(printf '%s\n' "$out" | grep -i -F -- "$pkg" || true)"
696
+ fi
697
+ [ -n "$out" ] || return 0
698
+ printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -8
699
+ }