@zhz8888/dsh-android-emulator-helper 1.0.0 → 1.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/README.md +33 -17
- package/README.zh-CN.md +22 -12
- package/lib/client.js +297 -105
- package/lib/index.js +15 -2
- package/lib/providers/avd.js +53 -4
- package/lib/providers/build.js +1 -1
- package/lib/providers/preflight.js +31 -4
- package/lib/providers/project-template.js +53 -0
- package/lib/providers/project.js +10 -2
- package/lib/providers/ui.js +68 -28
- package/lib/settings.js +56 -16
- package/package.json +1 -1
package/lib/providers/avd.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn as spawnChild } from 'node:child_process'
|
|
2
2
|
import { open, readFile } from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import { brief, ok, run, splitArgumentLines } from '../util/run.js'
|
|
5
6
|
import { systemImagePackage } from '../settings.js'
|
|
@@ -62,7 +63,8 @@ export function parseAvds(value) {
|
|
|
62
63
|
* @throws When nothing is ready and `autoStartEmulator` is off.
|
|
63
64
|
*/
|
|
64
65
|
export async function ensureDevice(rt, input = {}) {
|
|
65
|
-
|
|
66
|
+
// The page edits seconds; every spawn below wants milliseconds.
|
|
67
|
+
const timeoutMs = input.timeoutMs ?? rt.settings.bootTimeoutSeconds * 1000
|
|
66
68
|
if (input.serial) return waitForReadyTarget(rt, await pickDevice(rt, { serial: input.serial }), timeoutMs)
|
|
67
69
|
const ready = await pickDevice(rt).catch(() => undefined)
|
|
68
70
|
if (ready) return ready.kind === 'emulator' ? waitForReadyTarget(rt, ready, timeoutMs) : ready
|
|
@@ -88,7 +90,7 @@ export async function ensureDevice(rt, input = {}) {
|
|
|
88
90
|
* @throws When the AVD is already running, or the boot times out.
|
|
89
91
|
*/
|
|
90
92
|
export async function startEmulator(rt, input = {}) {
|
|
91
|
-
const timeoutMs = input.timeoutMs ?? rt.settings.
|
|
93
|
+
const timeoutMs = input.timeoutMs ?? rt.settings.bootTimeoutSeconds * 1000
|
|
92
94
|
if (input.serial) {
|
|
93
95
|
throw new Error(
|
|
94
96
|
'android_start_emulator always starts a new emulator. Reuse an existing target by passing serial to build, install, launch, screenshot, log, or UI tools.',
|
|
@@ -180,6 +182,11 @@ export async function createAvd(rt, input = {}) {
|
|
|
180
182
|
/**
|
|
181
183
|
* Choose the AVD to start: the requested one, the configured default, else the first.
|
|
182
184
|
*
|
|
185
|
+
* Only the *fallback* (nothing asked for, or the configured default is gone) may
|
|
186
|
+
* pick an AVD the caller did not name. An explicitly requested AVD that does not
|
|
187
|
+
* exist must fail: silently starting a different emulator turns a typo into a
|
|
188
|
+
* wrong device, and the caller never learns which AVD it is actually talking to.
|
|
189
|
+
*
|
|
183
190
|
* @param {object} rt - the call runtime.
|
|
184
191
|
* @param {string} [name] - the requested AVD name.
|
|
185
192
|
* @returns {Promise<{name: string}>} the chosen AVD.
|
|
@@ -187,9 +194,22 @@ export async function createAvd(rt, input = {}) {
|
|
|
187
194
|
async function pickAvd(rt, name) {
|
|
188
195
|
if (name && !AVD.test(name)) throw new Error(`Invalid Android AVD name: ${name}`)
|
|
189
196
|
const avds = await listAvds(rt)
|
|
190
|
-
|
|
197
|
+
if (name) {
|
|
198
|
+
// Exact match first: `includes` alone would let "Pixel" select "Pixel_10"
|
|
199
|
+
// even when an AVD literally called "Pixel" exists.
|
|
200
|
+
const hit =
|
|
201
|
+
avds.find((item) => item.name === name) ??
|
|
202
|
+
avds.find((item) => item.name.toLowerCase().includes(name.toLowerCase()))
|
|
203
|
+
if (hit) return hit
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Android AVD "${name}" not found. Available AVDs: ${avds.map((item) => item.name).join(', ') || 'none'}.`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
const want = rt.settings.defaultAvd
|
|
191
209
|
if (want) {
|
|
192
|
-
const hit =
|
|
210
|
+
const hit =
|
|
211
|
+
avds.find((item) => item.name === want) ??
|
|
212
|
+
avds.find((item) => item.name.toLowerCase().includes(want.toLowerCase()))
|
|
193
213
|
if (hit) return hit
|
|
194
214
|
}
|
|
195
215
|
const first = avds[0]
|
|
@@ -233,10 +253,39 @@ async function waitForStartedDevice(rt, before, avdName, timeoutMs, watch) {
|
|
|
233
253
|
const detail = last.length ? last.map((item) => `${item.serial}:${item.state}`).join(', ') : 'no adb devices'
|
|
234
254
|
throw new Error(
|
|
235
255
|
`Timed out waiting for Android Emulator to boot (${detail}).\n` +
|
|
256
|
+
blockerHint(last) +
|
|
236
257
|
`Log: ${watch.logPath}\n${await logTail(watch.logFile)}`,
|
|
237
258
|
)
|
|
238
259
|
}
|
|
239
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Explain the device states that stop a boot poll from ever succeeding.
|
|
263
|
+
*
|
|
264
|
+
* `startedEmulatorCandidates` only accepts a serial in `device` state, so an
|
|
265
|
+
* emulator sitting in `unauthorized` (an adb key handshake that never
|
|
266
|
+
* completed) or `offline` is invisible to the loop above: it polls until the
|
|
267
|
+
* timeout and reports only the boot timeout. Naming the real blocker turns an
|
|
268
|
+
* opaque timeout into something a caller can act on.
|
|
269
|
+
*
|
|
270
|
+
* @param {Array<{serial: string, state: string}>} devices - the devices seen on the last poll.
|
|
271
|
+
* @returns {string} a hint line, or an empty string when nothing was blocked.
|
|
272
|
+
*/
|
|
273
|
+
export function blockerHint(devices) {
|
|
274
|
+
const blocked = devices.filter((item) => item.state !== 'device')
|
|
275
|
+
if (blocked.length === 0) return ''
|
|
276
|
+
const unauthorized = blocked.filter((item) => item.state === 'unauthorized')
|
|
277
|
+
const lines = [`Blocking devices: ${blocked.map((item) => `${item.serial}:${item.state}`).join(', ')}.\n`]
|
|
278
|
+
if (unauthorized.length > 0) {
|
|
279
|
+
lines.push(
|
|
280
|
+
`An unauthorized emulator never accepts adb commands. Accept the RSA prompt on screen, ` +
|
|
281
|
+
`or wipe and restart the AVD with \`emulator -avd <name> -no-snapshot-load\`; ` +
|
|
282
|
+
`ADB_VENDOR_KEYS=${path.join(os.homedir(), '.android', 'adbkey')} must be readable by the adb server.\n`,
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
lines.push('')
|
|
286
|
+
return lines.join('')
|
|
287
|
+
}
|
|
288
|
+
|
|
240
289
|
/** Human-readable description of how a child process ended. */
|
|
241
290
|
function exitDescription(child) {
|
|
242
291
|
if (child.signalCode !== null && child.signalCode !== undefined) return `signal ${child.signalCode}`
|
package/lib/providers/build.js
CHANGED
|
@@ -38,7 +38,7 @@ export async function buildApp(rt, input = {}) {
|
|
|
38
38
|
const gradle = await gradleCommand(rt, root)
|
|
39
39
|
const out = await run([...gradle, task, ...splitArgumentLines(rt.settings.gradleExtraArgs)], {
|
|
40
40
|
cwd: root,
|
|
41
|
-
timeout: input.timeoutMs ?? rt.settings.
|
|
41
|
+
timeout: input.timeoutMs ?? rt.settings.buildTimeoutSeconds * 1000,
|
|
42
42
|
env: await androidEnv(rt),
|
|
43
43
|
})
|
|
44
44
|
const logPath = await record(rt, 'build', out)
|
|
@@ -17,7 +17,7 @@ import { androidEnv, checkTool, findTool, javaHome, listJavaHomes, sdkRoot, sdkR
|
|
|
17
17
|
* @module @zhz8888/dsh-android-emulator-helper/providers/preflight
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
/** Name of the ADB device check, exempted from the overall verdict. */
|
|
20
|
+
/** Name of the ADB device check, exempted from the overall verdict when a device can still be started. */
|
|
21
21
|
const ADB_DEVICES_CHECK = 'ADB devices'
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -28,16 +28,38 @@ const ADB_DEVICES_CHECK = 'ADB devices'
|
|
|
28
28
|
*/
|
|
29
29
|
const READY_TARGET_OPTIONAL_CHECKS = new Set(['emulator', 'avdmanager', 'Android Virtual Devices', 'Emulator acceleration'])
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether a missing ready device is still recoverable.
|
|
33
|
+
*
|
|
34
|
+
* `ensureDevice` starts an emulator when it finds no ready target, so "no
|
|
35
|
+
* devices" is not itself a failure — but only while that recovery is actually
|
|
36
|
+
* available. With `autoStartEmulator` off, or with no emulator binary or no
|
|
37
|
+
* AVD to start, every device-targeted tool refuses, and a verdict of "ready"
|
|
38
|
+
* would be a lie the caller only discovers on its first real call.
|
|
39
|
+
*
|
|
40
|
+
* @param {Array<{name: string, ok: boolean}>} checks - the preflight checks.
|
|
41
|
+
* @param {{autoStartEmulator?: boolean}} input - the live setting backing the recovery.
|
|
42
|
+
* @returns {boolean} true when the plugin can still produce a target on its own.
|
|
43
|
+
*/
|
|
44
|
+
function canStartTarget(checks, input) {
|
|
45
|
+
if (input.autoStartEmulator === false) return false
|
|
46
|
+
const failed = new Set(checks.filter((item) => !item.ok).map((item) => item.name))
|
|
47
|
+
return !failed.has('emulator') && !failed.has('Android Virtual Devices')
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
/**
|
|
32
51
|
* Reduce the checks to one verdict.
|
|
33
52
|
*
|
|
34
53
|
* @param {Array<{name: string, ok: boolean}>} checks - the preflight checks.
|
|
35
|
-
* @param {{hasReadyTarget: boolean}} input - whether any device is ready.
|
|
54
|
+
* @param {{hasReadyTarget: boolean, autoStartEmulator?: boolean}} input - whether any device is ready, and whether one could still be started.
|
|
36
55
|
* @returns {boolean} true when the environment can run the workflow.
|
|
37
56
|
*/
|
|
38
57
|
export function preflightOk(checks, input) {
|
|
58
|
+
const deviceOptional = input.hasReadyTarget || canStartTarget(checks, input)
|
|
39
59
|
return checks.every(
|
|
40
|
-
(item) =>
|
|
60
|
+
(item) =>
|
|
61
|
+
item.ok ||
|
|
62
|
+
(deviceOptional && (item.name === ADB_DEVICES_CHECK || READY_TARGET_OPTIONAL_CHECKS.has(item.name))),
|
|
41
63
|
)
|
|
42
64
|
}
|
|
43
65
|
|
|
@@ -147,7 +169,12 @@ export async function preflight(rt) {
|
|
|
147
169
|
fix: 'Start an Android Emulator or connect a device with USB debugging enabled.',
|
|
148
170
|
})
|
|
149
171
|
|
|
150
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
ok: preflightOk(checks, { hasReadyTarget, autoStartEmulator: rt.settings.autoStartEmulator !== false }),
|
|
174
|
+
backend: 'sdk',
|
|
175
|
+
settings: rt.settings,
|
|
176
|
+
checks,
|
|
177
|
+
}
|
|
151
178
|
}
|
|
152
179
|
|
|
153
180
|
/**
|
|
@@ -11,6 +11,38 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Lowest bytecode target the template will emit.
|
|
17
|
+
*
|
|
18
|
+
* Java 17 is the floor because AGP 8 requires a JDK 17 toolchain and the pinned
|
|
19
|
+
* Kotlin release targets 17 by default.
|
|
20
|
+
*/
|
|
21
|
+
export const MIN_JVM_TARGET = 17;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Highest bytecode target the template will emit.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately below the newest JDK: `JavaVersion.VERSION_24` does not exist in
|
|
27
|
+
* AGP 8.7, and the Kotlin release pinned here does not know `JvmTarget.JVM_24`
|
|
28
|
+
* either, so a generated project naming them fails at configuration time. The
|
|
29
|
+
* cap keeps a modern-only host building instead of emitting a file that cannot
|
|
30
|
+
* be evaluated.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_JVM_TARGET = 21;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Clamp a JDK major version to a bytecode target both plugins can name.
|
|
36
|
+
*
|
|
37
|
+
* @param {number|string|undefined} major - the JDK major the project will build with.
|
|
38
|
+
* @returns {number} a target between {@link MIN_JVM_TARGET} and {@link MAX_JVM_TARGET}.
|
|
39
|
+
*/
|
|
40
|
+
export function jvmTargetFor(major) {
|
|
41
|
+
const parsed = Number.parseInt(String(major ?? ""), 10);
|
|
42
|
+
if (!Number.isInteger(parsed)) return MIN_JVM_TARGET;
|
|
43
|
+
return Math.min(MAX_JVM_TARGET, Math.max(MIN_JVM_TARGET, parsed));
|
|
44
|
+
}
|
|
45
|
+
|
|
14
46
|
export function templateFiles(root, pkg, sdk) {
|
|
15
47
|
const pkgDir = pkg.replaceAll(".", "/");
|
|
16
48
|
return [
|
|
@@ -110,6 +142,27 @@ android {
|
|
|
110
142
|
versionCode = 1
|
|
111
143
|
versionName = "1.0"
|
|
112
144
|
}
|
|
145
|
+
|
|
146
|
+
// Both halves of a Compose build must agree on the bytecode target. Without
|
|
147
|
+
// these, AGP falls back to its own default (Java 8) while the Kotlin plugin
|
|
148
|
+
// derives its target from whichever JDK runs Gradle, and any JDK newer than
|
|
149
|
+
// the AGP release makes the two disagree:
|
|
150
|
+
//
|
|
151
|
+
// 'compileDebugJavaWithJavac' and 'compileDebugKotlin' have different JVM
|
|
152
|
+
// target compatibility: 1.8 and 22
|
|
153
|
+
//
|
|
154
|
+
// Pinning both to the same version is what makes a freshly generated project
|
|
155
|
+
// build on a machine whose only JDK is newer than the template's.
|
|
156
|
+
compileOptions {
|
|
157
|
+
sourceCompatibility = JavaVersion.VERSION_${input.jvmTarget}
|
|
158
|
+
targetCompatibility = JavaVersion.VERSION_${input.jvmTarget}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
kotlin {
|
|
163
|
+
compilerOptions {
|
|
164
|
+
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_${input.jvmTarget})
|
|
165
|
+
}
|
|
113
166
|
}
|
|
114
167
|
|
|
115
168
|
dependencies {
|
package/lib/providers/project.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { inside } from '../util/path.js'
|
|
4
|
-
import { templateFiles } from './project-template.js'
|
|
4
|
+
import { templateFiles, jvmTargetFor } from './project-template.js'
|
|
5
5
|
import { sdkRoot } from './sdk.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -97,6 +97,14 @@ export async function create(rt, input) {
|
|
|
97
97
|
const pkg = packageName(input.packageName || `com.example.${slug(name)}`)
|
|
98
98
|
const minSdk = sdkLevel(input.minSdk ?? rt.settings.minSdk, 'minSdk')
|
|
99
99
|
const compileSdk = sdkLevel(input.compileSdk ?? Number(rt.settings.apiLevel), 'compileSdk')
|
|
100
|
+
// The generated project pins ONE bytecode target for both its Java and its
|
|
101
|
+
// Kotlin compilation. Left unpinned, AGP uses its own default while the Kotlin
|
|
102
|
+
// plugin follows whichever JDK runs Gradle, and on a host whose JDK is newer
|
|
103
|
+
// than the pinned AGP release a freshly generated app fails to compile with
|
|
104
|
+
// "different JVM target compatibility". Deriving the target from the
|
|
105
|
+
// configured JDK major keeps the user's intent, and `jvmTargetFor` clamps it
|
|
106
|
+
// to a version both plugins can actually name.
|
|
107
|
+
const jvmTarget = jvmTargetFor(rt.settings.jdkMajor)
|
|
100
108
|
const files = templateFiles(root, pkg, await sdkRoot(rt))
|
|
101
109
|
|
|
102
110
|
if (!input.overwrite) {
|
|
@@ -110,7 +118,7 @@ export async function create(rt, input) {
|
|
|
110
118
|
|
|
111
119
|
for (const file of files) {
|
|
112
120
|
await mkdir(path.dirname(file.path), { recursive: true })
|
|
113
|
-
await writeFile(file.path, file.body({ name, pkg, minSdk, compileSdk }), 'utf8')
|
|
121
|
+
await writeFile(file.path, file.body({ name, pkg, minSdk, compileSdk, jvmTarget }), 'utf8')
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
return {
|
package/lib/providers/ui.js
CHANGED
|
@@ -47,17 +47,45 @@ export async function status(rt) {
|
|
|
47
47
|
/**
|
|
48
48
|
* Dump the current screen's UI Automator tree.
|
|
49
49
|
*
|
|
50
|
+
* `uiautomator dump` reports success by exit code even when it wrote nothing
|
|
51
|
+
* (a null root node is a common cause and still exits 0), and the dump path is
|
|
52
|
+
* persistent. Dumping to one fixed path and reading it back therefore has a
|
|
53
|
+
* silent failure mode: a failed dump leaves the PREVIOUS file in place, and the
|
|
54
|
+
* caller is handed a stale screen as if it were current.
|
|
55
|
+
*
|
|
56
|
+
* The dump therefore goes to a path unique to this call, which both removes the
|
|
57
|
+
* staleness (nothing to read means an empty tree, not the last one) and lets two
|
|
58
|
+
* concurrent callers stop racing on one shared file. The file is removed again
|
|
59
|
+
* so repeated calls do not accumulate on the device.
|
|
60
|
+
*
|
|
50
61
|
* @param {object} rt - the call runtime.
|
|
51
62
|
* @param {{serial?: string, avd?: string, timeoutMs?: number}} [input] - the tool arguments.
|
|
52
63
|
* @returns {Promise<{device: object, elements: Array<object>}>} the device and its flattened elements.
|
|
53
64
|
*/
|
|
54
65
|
export async function describe(rt, input = {}) {
|
|
55
66
|
const device = await ensureDevice(rt, input)
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
67
|
+
const timeout = input.timeoutMs ?? 30_000
|
|
68
|
+
const remote = `/sdcard/.dsh-ui-${process.pid}-${Date.now()}-${dumpSeq++}.xml`
|
|
69
|
+
try {
|
|
70
|
+
await adbShell(rt, device.serial, ['uiautomator', 'dump', remote], timeout)
|
|
71
|
+
const out = await adbShell(rt, device.serial, ['cat', remote], timeout)
|
|
72
|
+
if (!looksLikeDump(out.stdout)) {
|
|
73
|
+
// Reported rather than answered with an empty tree: "no elements" is a
|
|
74
|
+
// legitimate screen, so silently returning it would let a failed dump pass
|
|
75
|
+
// for an empty UI and send the caller looking for a bug in its own query.
|
|
76
|
+
throw new Error(
|
|
77
|
+
`UI Automator produced no dump for ${device.serial}. The screen may be mid-transition, or a secure window is in front. Retry once the app has settled.`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return { device, elements: parseUiAutomator(out.stdout) }
|
|
81
|
+
} finally {
|
|
82
|
+
await adbShell(rt, device.serial, ['rm', '-f', remote], timeout).catch(() => undefined)
|
|
83
|
+
}
|
|
59
84
|
}
|
|
60
85
|
|
|
86
|
+
/** Counter making each dump path unique within this process. */
|
|
87
|
+
let dumpSeq = 0
|
|
88
|
+
|
|
61
89
|
/**
|
|
62
90
|
* Resolve a visible element to tap coordinates.
|
|
63
91
|
*
|
|
@@ -84,13 +112,13 @@ export async function resolve(rt, input) {
|
|
|
84
112
|
*/
|
|
85
113
|
export async function tap(rt, input) {
|
|
86
114
|
const device = await ensureDevice(rt, input)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)
|
|
93
|
-
return { tapped: true, serial: device.serial, x
|
|
115
|
+
// Report what was sent, not what was asked for: `input tap` takes integers, so
|
|
116
|
+
// a fractional request is rounded on the way out and the caller would
|
|
117
|
+
// otherwise be told a coordinate the device never received.
|
|
118
|
+
const x = Math.round(input.x)
|
|
119
|
+
const y = Math.round(input.y)
|
|
120
|
+
await adbShell(rt, device.serial, ['input', 'tap', String(x), String(y)], input.timeoutMs ?? 30_000)
|
|
121
|
+
return { tapped: true, serial: device.serial, x, y }
|
|
94
122
|
}
|
|
95
123
|
|
|
96
124
|
/**
|
|
@@ -102,28 +130,22 @@ export async function tap(rt, input) {
|
|
|
102
130
|
*/
|
|
103
131
|
export async function swipe(rt, input) {
|
|
104
132
|
const device = await ensureDevice(rt, input)
|
|
133
|
+
const x1 = Math.round(input.x1)
|
|
134
|
+
const y1 = Math.round(input.y1)
|
|
135
|
+
const x2 = Math.round(input.x2)
|
|
136
|
+
const y2 = Math.round(input.y2)
|
|
137
|
+
const durationMs = Math.round(input.durationMs ?? 300)
|
|
105
138
|
await adbShell(
|
|
106
139
|
rt,
|
|
107
140
|
device.serial,
|
|
108
|
-
[
|
|
109
|
-
'input',
|
|
110
|
-
'swipe',
|
|
111
|
-
String(Math.round(input.x1)),
|
|
112
|
-
String(Math.round(input.y1)),
|
|
113
|
-
String(Math.round(input.x2)),
|
|
114
|
-
String(Math.round(input.y2)),
|
|
115
|
-
String(Math.round(input.durationMs ?? 300)),
|
|
116
|
-
],
|
|
141
|
+
['input', 'swipe', String(x1), String(y1), String(x2), String(y2), String(durationMs)],
|
|
117
142
|
input.timeoutMs ?? 30_000,
|
|
118
143
|
)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
x2: input.x2,
|
|
125
|
-
y2: input.y2,
|
|
126
|
-
}
|
|
144
|
+
// `durationMs` is part of the report because it is the one gesture parameter a
|
|
145
|
+
// caller cannot infer from the visible result: a clamped list moves the same
|
|
146
|
+
// distance at every duration, so without it there is no way to confirm which
|
|
147
|
+
// value actually ran.
|
|
148
|
+
return { swiped: true, serial: device.serial, x1, y1, x2, y2, durationMs }
|
|
127
149
|
}
|
|
128
150
|
|
|
129
151
|
/**
|
|
@@ -180,6 +202,15 @@ export function parseUiAutomator(value) {
|
|
|
180
202
|
return elements
|
|
181
203
|
}
|
|
182
204
|
|
|
205
|
+
/** Whether a dump result is a UI Automator document rather than an error message. */
|
|
206
|
+
function looksLikeDump(value) {
|
|
207
|
+
const text = String(value ?? '').trim()
|
|
208
|
+
if (!text.startsWith('<')) return false
|
|
209
|
+
// An empty hierarchy is still a valid dump; it is the absent document — the
|
|
210
|
+
// `cat` of a file the failed dump never wrote — that must not read as "no UI".
|
|
211
|
+
return /<hierarchy\b/.test(text)
|
|
212
|
+
}
|
|
213
|
+
|
|
183
214
|
/** Parse the attribute list of one `<node>` tag. */
|
|
184
215
|
function attributes(value) {
|
|
185
216
|
const out = new Map()
|
|
@@ -210,6 +241,15 @@ function parseBounds(value) {
|
|
|
210
241
|
/**
|
|
211
242
|
* Escape text for `adb shell input text`, which uses `%s` for spaces.
|
|
212
243
|
*
|
|
244
|
+
* `input text` is a small custom parser, not a URL decoder: the ONLY escape it
|
|
245
|
+
* understands is `%s` for a space. Percent signs pass through unchanged, so
|
|
246
|
+
* replacing them with `%25` does not protect anything — the device types the
|
|
247
|
+
* four literal characters and the caller's text comes back corrupted.
|
|
248
|
+
*
|
|
249
|
+
* The residual limitation is the mirror image: a literal `%s` in the caller's
|
|
250
|
+
* text cannot be represented, because the device reads it as a space. That is
|
|
251
|
+
* documented rather than worked around, since escaping it is impossible.
|
|
252
|
+
*
|
|
213
253
|
* @param {string} value - the raw text.
|
|
214
254
|
* @returns {string} the escaped text.
|
|
215
255
|
* @throws When the text is longer than {@link MAX_INPUT_TEXT_LENGTH}.
|
|
@@ -217,7 +257,7 @@ function parseBounds(value) {
|
|
|
217
257
|
export function inputText(value) {
|
|
218
258
|
if (typeof value !== 'string') throw new Error('Text input must be a string.')
|
|
219
259
|
if (value.length > MAX_INPUT_TEXT_LENGTH) throw new Error('Text input is too long.')
|
|
220
|
-
return value.replace(
|
|
260
|
+
return value.replace(/\s/g, '%s')
|
|
221
261
|
}
|
|
222
262
|
|
|
223
263
|
/** Searchable text of one element. */
|
package/lib/settings.js
CHANGED
|
@@ -39,6 +39,14 @@ export const FIELD_GROUPS = [
|
|
|
39
39
|
{
|
|
40
40
|
id: 'sdk',
|
|
41
41
|
env: 'ANDROID_PLUGIN_SDK_PATH',
|
|
42
|
+
// The heading and hint the settings page renders around this card. The
|
|
43
|
+
// browser half cannot import this module, so it carries its own copy;
|
|
44
|
+
// `scripts/check-bundle.mjs` compares the two word for word.
|
|
45
|
+
title: { zh: 'SDK 与工具链', en: 'SDK and toolchain' },
|
|
46
|
+
hint: {
|
|
47
|
+
zh: '预检、编译与 sdkmanager 使用的 Android SDK 与 JDK 位置。',
|
|
48
|
+
en: 'The Android SDK and JDK the diagnostic, the build and sdkmanager use.',
|
|
49
|
+
},
|
|
42
50
|
fields: [
|
|
43
51
|
{
|
|
44
52
|
key: 'sdkPath',
|
|
@@ -60,8 +68,8 @@ export const FIELD_GROUPS = [
|
|
|
60
68
|
placeholder: '17',
|
|
61
69
|
label: { zh: 'JDK 主版本', en: 'JDK major version' },
|
|
62
70
|
hint: {
|
|
63
|
-
zh: '
|
|
64
|
-
en:
|
|
71
|
+
zh: '用于预检与安装指引;下拉项来自本机已安装的 JDK。',
|
|
72
|
+
en: "Used by the diagnostic and the install guidance; the suggestions come from this machine's JDKs.",
|
|
65
73
|
},
|
|
66
74
|
},
|
|
67
75
|
{
|
|
@@ -72,14 +80,19 @@ export const FIELD_GROUPS = [
|
|
|
72
80
|
placeholder: '35.0.0',
|
|
73
81
|
label: { zh: 'Build-tools 版本', en: 'Build-tools version' },
|
|
74
82
|
hint: {
|
|
75
|
-
zh: '
|
|
76
|
-
en: 'The Android SDK build-tools version named by
|
|
83
|
+
zh: '安装指引中引用的 Android SDK build-tools 版本。',
|
|
84
|
+
en: 'The Android SDK build-tools version named by the install guidance.',
|
|
77
85
|
},
|
|
78
86
|
},
|
|
79
87
|
],
|
|
80
88
|
},
|
|
81
89
|
{
|
|
82
90
|
id: 'emulator',
|
|
91
|
+
title: { zh: '模拟器与设备', en: 'Emulator and devices' },
|
|
92
|
+
hint: {
|
|
93
|
+
zh: '选择默认 AVD、如何启动模拟器,以及传给 emulator 的命令行参数。',
|
|
94
|
+
en: 'The default AVD, how the emulator starts, and the arguments it receives.',
|
|
95
|
+
},
|
|
83
96
|
fields: [
|
|
84
97
|
{
|
|
85
98
|
key: 'defaultAvd',
|
|
@@ -126,16 +139,24 @@ export const FIELD_GROUPS = [
|
|
|
126
139
|
},
|
|
127
140
|
},
|
|
128
141
|
{
|
|
129
|
-
key: '
|
|
142
|
+
key: 'bootTimeoutSeconds',
|
|
130
143
|
kind: 'number',
|
|
131
|
-
default:
|
|
132
|
-
label: { zh: '
|
|
133
|
-
hint: {
|
|
144
|
+
default: 180,
|
|
145
|
+
label: { zh: '模拟器启动超时(秒)', en: 'Emulator boot timeout (seconds)' },
|
|
146
|
+
hint: {
|
|
147
|
+
zh: '等待模拟器完成启动的最长时间,默认 180 秒。',
|
|
148
|
+
en: 'Longest wait for the emulator to finish booting; 180 seconds by default.',
|
|
149
|
+
},
|
|
134
150
|
},
|
|
135
151
|
],
|
|
136
152
|
},
|
|
137
153
|
{
|
|
138
154
|
id: 'image',
|
|
155
|
+
title: { zh: 'API 级别与系统镜像', en: 'API level and system image' },
|
|
156
|
+
hint: {
|
|
157
|
+
zh: '生成项目、安装 SDK 包与创建 AVD 时使用的默认值。',
|
|
158
|
+
en: 'Defaults used when generating a project, installing SDK packages and creating an AVD.',
|
|
159
|
+
},
|
|
139
160
|
fields: [
|
|
140
161
|
{
|
|
141
162
|
key: 'apiLevel',
|
|
@@ -166,8 +187,8 @@ export const FIELD_GROUPS = [
|
|
|
166
187
|
options: ['', ...SYSTEM_IMAGE_ABIS],
|
|
167
188
|
label: { zh: '系统镜像 ABI', en: 'System image ABI' },
|
|
168
189
|
hint: {
|
|
169
|
-
zh: '
|
|
170
|
-
en: '
|
|
190
|
+
zh: '自动时 Apple Silicon 用 arm64-v8a,其他宿主用 x86_64。',
|
|
191
|
+
en: 'Automatic uses arm64-v8a on Apple Silicon and x86_64 elsewhere.',
|
|
171
192
|
},
|
|
172
193
|
},
|
|
173
194
|
{
|
|
@@ -175,19 +196,30 @@ export const FIELD_GROUPS = [
|
|
|
175
196
|
kind: 'number',
|
|
176
197
|
default: 23,
|
|
177
198
|
label: { zh: '默认 minSdk', en: 'Default minSdk' },
|
|
178
|
-
hint: {
|
|
199
|
+
hint: {
|
|
200
|
+
zh: 'android_create_app 生成新项目时的默认 minSdk,默认 23。',
|
|
201
|
+
en: 'Default minSdk for a project created by android_create_app; 23 by default.',
|
|
202
|
+
},
|
|
179
203
|
},
|
|
180
204
|
],
|
|
181
205
|
},
|
|
182
206
|
{
|
|
183
207
|
id: 'build',
|
|
208
|
+
title: { zh: '构建', en: 'Build' },
|
|
209
|
+
hint: {
|
|
210
|
+
zh: 'Gradle 构建的超时与附加参数。',
|
|
211
|
+
en: 'Gradle timeouts and extra arguments.',
|
|
212
|
+
},
|
|
184
213
|
fields: [
|
|
185
214
|
{
|
|
186
|
-
key: '
|
|
215
|
+
key: 'buildTimeoutSeconds',
|
|
187
216
|
kind: 'number',
|
|
188
|
-
default:
|
|
189
|
-
label: { zh: 'Gradle
|
|
190
|
-
hint: {
|
|
217
|
+
default: 600,
|
|
218
|
+
label: { zh: 'Gradle 构建超时(秒)', en: 'Gradle build timeout (seconds)' },
|
|
219
|
+
hint: {
|
|
220
|
+
zh: '一次 assemble 任务的最长运行时间,默认 600 秒。',
|
|
221
|
+
en: 'Longest runtime of one assemble task; 600 seconds by default.',
|
|
222
|
+
},
|
|
191
223
|
},
|
|
192
224
|
{
|
|
193
225
|
key: 'gradleExtraArgs',
|
|
@@ -201,6 +233,11 @@ export const FIELD_GROUPS = [
|
|
|
201
233
|
},
|
|
202
234
|
{
|
|
203
235
|
id: 'output',
|
|
236
|
+
title: { zh: '输出目录与日志', en: 'Output and logs' },
|
|
237
|
+
hint: {
|
|
238
|
+
zh: '截图与构建日志的落盘位置。',
|
|
239
|
+
en: 'Where screenshots and build logs are written.',
|
|
240
|
+
},
|
|
204
241
|
fields: [
|
|
205
242
|
{
|
|
206
243
|
key: 'dataDir',
|
|
@@ -226,7 +263,10 @@ export const FIELD_GROUPS = [
|
|
|
226
263
|
kind: 'number',
|
|
227
264
|
default: 500,
|
|
228
265
|
label: { zh: '默认 logcat 行数', en: 'Default logcat lines' },
|
|
229
|
-
hint: {
|
|
266
|
+
hint: {
|
|
267
|
+
zh: 'android_logs 未指定行数时读取的最近行数,默认 500。',
|
|
268
|
+
en: 'Lines android_logs reads when the call names none; 500 by default.',
|
|
269
|
+
},
|
|
230
270
|
},
|
|
231
271
|
],
|
|
232
272
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhz8888/dsh-android-emulator-helper",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"icon": "./icon.svg",
|
|
5
5
|
"description": "Android SDK, emulator and device management plus Kotlin/Compose build-and-run tools for DeepSeek Harness, ported from the official ZCode android-emulator plugin, with a full in-app settings page.",
|
|
6
6
|
"keywords": [
|