@skrillex1224/android-toolkit 0.1.2 → 0.1.8

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.
@@ -20,11 +20,19 @@ import {Logger, sleep} from './logger.js';
20
20
  * 3. 收集 agent 打出的 marker 行并在 stop() 时返回。
21
21
  */
22
22
  export async function startWebViewEventRecorder(ctx, config = {}, options = {}) {
23
+ const startedAt = Date.now();
24
+ await ensureFridaServer(ctx);
23
25
  assertFridaReady(ctx, ctx.fridaWebViewEventAgentScript, 'frida webview event agent script');
24
26
  const marker = options.marker || 'ANDROID_WEBVIEW_EVENT_JSON ';
25
27
  const scriptPath = writeConfiguredWebViewEventAgent(ctx, config);
26
28
  const pid = await resolvePid(ctx, ctx.packageName);
27
- Logger.info(`frida webview event recorder attach pid=${pid}`);
29
+ Logger.info('frida webview recorder start', {
30
+ label: options.label || 'webview-event-recorder',
31
+ serial: ctx.serial,
32
+ packageName: ctx.packageName,
33
+ pid,
34
+ scriptPath
35
+ });
28
36
 
29
37
  const recorder = startFridaLineRecorder(ctx.fridaPath, [
30
38
  '-q',
@@ -43,6 +51,7 @@ export async function startWebViewEventRecorder(ctx, config = {}, options = {})
43
51
  outputPath: options.outputPath || ''
44
52
  });
45
53
  await recorder.waitForStartup(Number(options.startupTimeoutMs || 3000));
54
+ Logger.info('frida webview recorder ready', {duration: Logger.duration(startedAt), pid});
46
55
  return recorder;
47
56
  }
48
57
 
@@ -54,26 +63,20 @@ export async function startWebViewEventRecorder(ctx, config = {}, options = {})
54
63
  * 收集输出和错误显式化。
55
64
  */
56
65
  export async function runScript(ctx, source, options = {}) {
57
- const attempts = Math.max(1, Number(options.attempts || 3));
58
- let lastError = null;
59
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
60
- try {
61
- return await runScriptOnce(ctx, source, options);
62
- } catch (error) {
63
- lastError = error;
64
- if (attempt >= attempts || error?.code !== Code.FridaUnavailable) throw error;
65
- Logger.warn(`frida ${options.label || 'script'} 第 ${attempt} 次未返回,准备重试`);
66
- await sleep(Number(options.retryDelayMs || 800));
67
- }
68
- }
69
- throw lastError;
70
- }
71
-
72
- async function runScriptOnce(ctx, source, options = {}) {
66
+ const startedAt = Date.now();
67
+ const label = options.label || 'script';
68
+ await ensureFridaServer(ctx);
73
69
  assertFridaReady(ctx, ctx.fridaWebViewEventAgentScript, 'frida webview event agent script');
74
70
  const marker = options.marker || 'ANDROID_TOOLKIT_SCRIPT_JSON ';
75
- const scriptPath = writeTempScript(ctx, source, options.label || 'script');
71
+ const scriptPath = writeTempScript(ctx, source, label);
76
72
  const pid = await resolvePid(ctx, options.packageName || ctx.packageName);
73
+ Logger.info('frida script start', {
74
+ label,
75
+ serial: ctx.serial,
76
+ packageName: options.packageName || ctx.packageName,
77
+ pid,
78
+ timeoutMs: options.timeoutMs || 10000
79
+ });
77
80
  const recorder = startFridaLineRecorder(ctx.fridaPath, [
78
81
  '-q',
79
82
  '-t',
@@ -94,13 +97,216 @@ async function runScriptOnce(ctx, source, options = {}) {
94
97
  const result = await recorder.stop();
95
98
  const scriptEvents = result.events.filter((event) => event?.type !== 'parse_error');
96
99
  if (scriptEvents.length === 0) {
100
+ Logger.warn('frida script produced no event', {
101
+ label,
102
+ duration: Logger.duration(startedAt),
103
+ lineCount: result.lines.length
104
+ });
97
105
  throw new CrawlerError({
98
- message: `frida_script_failed: ${options.label || 'script'} did not emit ${marker.trim()}`,
106
+ message: `frida_script_failed: ${label} did not emit ${marker.trim()}`,
99
107
  code: Code.FridaUnavailable,
100
108
  context: {lines: result.lines.slice(-40)}
101
109
  });
102
110
  }
103
- return scriptEvents.at(-1);
111
+ const event = scriptEvents.at(-1);
112
+ Logger.info('frida script done', {
113
+ label,
114
+ duration: Logger.duration(startedAt),
115
+ eventCount: scriptEvents.length,
116
+ ok: event?.ok
117
+ });
118
+ return event;
119
+ }
120
+
121
+ /**
122
+ * 在目标 App 进程内查询 SQLite。
123
+ *
124
+ * 这是通用底层能力,只负责:
125
+ * - 按 dbPath 或 dbDir/dbNamePrefix 找数据库文件;
126
+ * - 执行业务方传入的 SQL 和参数;
127
+ * - 把 cursor rows 作为字符串对象返回。
128
+ *
129
+ * toolkit 不理解表名、字段含义、消息关联关系;这些规则必须留在具体 androider。
130
+ */
131
+ export async function querySQLite(ctx, options = {}) {
132
+ if (!options.sql) {
133
+ throw new CrawlerError({
134
+ message: 'sqlite_query_failed: sql is required',
135
+ code: Code.InvalidRequest
136
+ });
137
+ }
138
+
139
+ const startedAt = Date.now();
140
+ const config = {
141
+ dbPath: String(options.dbPath || ''),
142
+ dbDir: String(options.dbDir || ''),
143
+ dbNamePrefix: String(options.dbNamePrefix || ''),
144
+ dbNameIncludes: String(options.dbNameIncludes || ''),
145
+ dbNameExcludes: normalizeStringArray(options.dbNameExcludes),
146
+ sql: String(options.sql || ''),
147
+ args: normalizeStringArray(options.args),
148
+ maxRows: Math.max(1, Number(options.maxRows || 200))
149
+ };
150
+ Logger.info('sqlite query start', {
151
+ label: options.label || 'query-sqlite',
152
+ dbPath: config.dbPath,
153
+ dbDir: config.dbDir,
154
+ maxRows: config.maxRows
155
+ });
156
+
157
+ const event = await runScript(ctx, `
158
+ Java.perform(function () {
159
+ function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
160
+ var config = ${JSON.stringify(config)};
161
+ var JavaString = Java.use('java.lang.String');
162
+
163
+ function safeString(cursor, index) {
164
+ try { return cursor.isNull(index) ? '' : String(cursor.getString(index)); } catch (_) { return ''; }
165
+ }
166
+ function stringArray(values) {
167
+ if (!values || values.length === 0) return null;
168
+ var out = [];
169
+ for (var i = 0; i < values.length; i++) out.push(String(values[i]));
170
+ return Java.array('java.lang.String', out);
171
+ }
172
+ function matchesName(name) {
173
+ if (config.dbNamePrefix && name.indexOf(config.dbNamePrefix) !== 0) return false;
174
+ if (config.dbNameIncludes && name.indexOf(config.dbNameIncludes) < 0) return false;
175
+ for (var i = 0; i < config.dbNameExcludes.length; i++) {
176
+ if (String(name).indexOf(config.dbNameExcludes[i]) >= 0) return false;
177
+ }
178
+ return true;
179
+ }
180
+ function dbPaths() {
181
+ if (config.dbPath) return [config.dbPath];
182
+ var File = Java.use('java.io.File');
183
+ var dir = File.$new(config.dbDir);
184
+ var files = dir.listFiles();
185
+ var out = [];
186
+ if (!files) return out;
187
+ for (var i = 0; i < files.length; i++) {
188
+ var name = String(files[i].getName());
189
+ if (matchesName(name)) out.push(String(files[i].getAbsolutePath()));
190
+ }
191
+ return out;
192
+ }
193
+ function queryOne(dbPath) {
194
+ var SQLiteDatabase = Java.use('android.database.sqlite.SQLiteDatabase');
195
+ var db = SQLiteDatabase.openDatabase(JavaString.$new(dbPath), null, 1);
196
+ try {
197
+ var cursor = db.rawQuery(JavaString.$new(config.sql), stringArray(config.args));
198
+ var columns = [];
199
+ var columnCount = cursor.getColumnCount();
200
+ for (var c = 0; c < columnCount; c++) columns.push(String(cursor.getColumnName(c)));
201
+ var rows = [];
202
+ var truncated = false;
203
+ while (cursor.moveToNext()) {
204
+ if (rows.length >= config.maxRows) {
205
+ truncated = true;
206
+ break;
207
+ }
208
+ var row = {};
209
+ for (var i = 0; i < columns.length; i++) row[columns[i]] = safeString(cursor, i);
210
+ rows.push(row);
211
+ }
212
+ cursor.close();
213
+ return { dbPath: dbPath, columns: columns, rows: rows, rowCount: rows.length, truncated: truncated };
214
+ } finally {
215
+ db.close();
216
+ }
217
+ }
218
+ try {
219
+ var paths = dbPaths();
220
+ if (paths.length === 0) {
221
+ emit({ ok: false, error: 'sqlite database not found', config: config });
222
+ return;
223
+ }
224
+ var databases = [];
225
+ for (var d = 0; d < paths.length; d++) databases.push(queryOne(paths[d]));
226
+ emit({ ok: true, databases: databases, rows: databases.length === 1 ? databases[0].rows : [] });
227
+ } catch (error) {
228
+ emit({ ok: false, error: String(error), stack: String(error.stack || '') });
229
+ }
230
+ });
231
+ `, {
232
+ label: options.label || 'query-sqlite',
233
+ packageName: options.packageName || ctx.packageName,
234
+ timeoutMs: options.timeoutMs || 12000,
235
+ fridaTimeoutSeconds: options.fridaTimeoutSeconds || 10,
236
+ maxLines: options.maxLines || 1200
237
+ });
238
+ if (!event.ok) {
239
+ throw new CrawlerError({
240
+ message: `sqlite_query_failed: ${event.error || ''}`.trim(),
241
+ code: Code.ContentUnavailable,
242
+ context: event
243
+ });
244
+ }
245
+ Logger.info('sqlite query done', {
246
+ label: options.label || 'query-sqlite',
247
+ duration: Logger.duration(startedAt),
248
+ databaseCount: Array.isArray(event.databases) ? event.databases.length : 0,
249
+ rowCount: Array.isArray(event.rows) ? event.rows.length : 0
250
+ });
251
+ return event;
252
+ }
253
+
254
+ /**
255
+ * 确保设备侧 frida-server 已经启动。
256
+ *
257
+ * 安卓采集链路依赖 host 侧 `frida -D <serial>` attach 到目标 App;
258
+ * 如果设备重启、frida-server 被系统杀掉,或者被普通 shell 用户启动,
259
+ * 后续 attach 会直接失败。这里在每次 Frida 调用前做轻量检查:
260
+ * - root 用户的 frida-server 已存在:直接复用;
261
+ * - 只有 shell 用户进程:用 su 清理后重启;
262
+ * - 没有进程:优先用 su 启动 `/data/local/tmp/frida-server`,再退回 shell 启动。
263
+ */
264
+ export async function ensureFridaServer(ctx, options = {}) {
265
+ const serverPath = options.serverPath || ctx.fridaServerPath || '/data/local/tmp/frida-server';
266
+ const current = await listFridaServerRows(ctx);
267
+ if (current.some((row) => row.user === 'root')) {
268
+ Logger.debug('frida-server ready', {serial: ctx.serial, user: 'root'});
269
+ return true;
270
+ }
271
+
272
+ const rootAvailable = await canUseSu(ctx);
273
+ if (current.length > 0 && rootAvailable) {
274
+ Logger.warn('frida-server 正在以非 root 用户运行,尝试切换为 root 进程');
275
+ await adbShell(ctx, ['su', '-c', 'pkill -f frida-server || true'], {timeoutMs: 5000}).catch(() => { });
276
+ await sleep(800);
277
+ } else if (current.length > 0) {
278
+ Logger.debug('frida-server ready', {serial: ctx.serial, user: 'shell'});
279
+ return true;
280
+ }
281
+
282
+ const exists = await adbShell(ctx, ['ls', serverPath], {timeoutMs: 5000}).then(() => true).catch(() => false);
283
+ if (!exists) {
284
+ throw new CrawlerError({
285
+ message: `frida_unavailable: frida-server not found on device: ${serverPath}`,
286
+ code: Code.FridaUnavailable,
287
+ context: {serverPath}
288
+ });
289
+ }
290
+
291
+ const command = `chmod 755 ${shellQuote(serverPath)} && ${shellQuote(serverPath)} >/dev/null 2>&1 &`;
292
+ if (rootAvailable) {
293
+ await adbShell(ctx, ['su', '-c', command], {timeoutMs: 5000}).catch(() => { });
294
+ } else {
295
+ await adbShell(ctx, ['sh', '-c', command], {timeoutMs: 5000}).catch(() => { });
296
+ }
297
+
298
+ await sleep(1500);
299
+ const after = await listFridaServerRows(ctx);
300
+ if (after.some((row) => row.user === 'root') || (!rootAvailable && after.length > 0)) {
301
+ Logger.info('frida-server started', {serial: ctx.serial, rootAvailable});
302
+ return true;
303
+ }
304
+
305
+ throw new CrawlerError({
306
+ message: 'frida_unavailable: frida-server did not start on device',
307
+ code: Code.FridaUnavailable,
308
+ context: {serverPath, rootAvailable, current, after}
309
+ });
104
310
  }
105
311
 
106
312
  /**
@@ -132,7 +338,8 @@ Java.perform(function () {
132
338
  `, {
133
339
  label: options.label || 'start-activity-inside-app',
134
340
  packageName: options.packageName || ctx.packageName,
135
- timeoutMs: options.timeoutMs || 7000
341
+ timeoutMs: options.timeoutMs || 12000,
342
+ fridaTimeoutSeconds: options.fridaTimeoutSeconds || 10
136
343
  });
137
344
  if (!event.ok) {
138
345
  throw new CrawlerError({
@@ -145,16 +352,27 @@ Java.perform(function () {
145
352
  }
146
353
 
147
354
  /**
148
- * 在当前 App 内部 View 树里查找可见文本节点,返回真实屏幕 bounds。
355
+ * 查找当前 App 内部可见 EditText,并直接写入文本。
149
356
  *
150
- * 这不是 OCR,也不依赖系统 uiautomator 无障碍树;它在 App 进程内遍历
151
- * Activity DecorView。微信这类 App 经常不给 uiautomator 暴露节点,但
152
- * App 内部 View 树仍然能拿到 TextView / EditText / List item 的位置。
357
+ * 这是 Android 版的“定位输入框并 fill”基础能力:
358
+ * - 不走 OCR;
359
+ * - 不依赖屏幕比例坐标;
360
+ * - 业务方只传通用筛选条件,例如 hintIncludes;
361
+ * - toolkit 在 App 进程内遍历 Activity DecorView,选中输入框后 requestFocus/performClick,
362
+ * 再写入 Editable,保证中文不会被 adb input text 打成乱码。
153
363
  */
154
- export async function findVisibleTextBounds(ctx, text, options = {}) {
364
+ export async function setVisibleInputText(ctx, text, options = {}) {
155
365
  const event = await runScript(ctx, `
156
366
  Java.perform(function () {
157
367
  function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
368
+ var JavaObject = Java.use('java.lang.Object');
369
+ function toJsString(value) {
370
+ if (value === null || value === undefined) return '';
371
+ try { return Java.cast(value, JavaObject).toString() + ''; } catch (_) { }
372
+ try { return value.toString.overload().call(value) + ''; } catch (_) { }
373
+ try { return value.toString() + ''; } catch (_) { }
374
+ return '';
375
+ }
158
376
  function rectOf(view) {
159
377
  var Rect = Java.use('android.graphics.Rect');
160
378
  var rect = Rect.$new();
@@ -190,163 +408,191 @@ Java.perform(function () {
190
408
  }
191
409
  return out;
192
410
  }
193
- try {
194
- var target = ${JSON.stringify(String(text || ''))};
195
- var exact = ${options.exact === false ? 'false' : 'true'};
196
- var TextView = Java.use('android.widget.TextView');
197
- var matches = [];
198
- var activities = activeActivities();
199
- for (var a = 0; a < activities.length; a++) {
200
- var views = [];
201
- walk(activities[a].getWindow().getDecorView(), views, 0);
202
- for (var i = 0; i < views.length; i++) {
203
- var view = views[i];
204
- if (!view.isShown()) continue;
205
- var value = '';
206
- try { value = String(Java.cast(view, TextView).getText().toString()); } catch (_) { continue; }
207
- if (exact ? value === target : value.indexOf(target) >= 0) {
208
- var rect = rectOf(view);
209
- if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
210
- matches.push({
211
- text: value,
212
- className: String(view.getClass().getName()),
213
- idName: (function () { try { return String(view.getResources().getResourceName(view.getId())); } catch (_) { return ''; } })(),
214
- bounds: rect
215
- });
411
+ Java.scheduleOnMainThread(function () {
412
+ try {
413
+ var EditText = Java.use('android.widget.EditText');
414
+ var InputMethodManager = Java.use('android.view.inputmethod.InputMethodManager');
415
+ var targetText = ${JSON.stringify(String(text || ''))};
416
+ var hintIncludes = ${JSON.stringify(String(options.hintIncludes || ''))};
417
+ var activities = activeActivities();
418
+ var chosen = null;
419
+ var focused = null;
420
+ var firstVisible = null;
421
+ for (var a = 0; a < activities.length; a++) {
422
+ var views = [];
423
+ walk(activities[a].getWindow().getDecorView(), views, 0);
424
+ for (var i = 0; i < views.length; i++) {
425
+ var view = views[i];
426
+ if (!view.isShown()) continue;
427
+ try {
428
+ var editText = Java.cast(view, EditText);
429
+ var rect = rectOf(view);
430
+ if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
431
+ var item = { view: editText, bounds: rect, text: toJsString(editText.getText()), hint: toJsString(editText.getHint()), className: toJsString(view.getClass().getName()), focused: Boolean(view.hasFocus()) };
432
+ if (hintIncludes && item.hint.indexOf(hintIncludes) >= 0) {
433
+ chosen = item;
434
+ break;
435
+ }
436
+ if (item.focused && !focused) focused = item;
437
+ if (!firstVisible) firstVisible = item;
438
+ } catch (_) { }
216
439
  }
440
+ if (chosen) break;
217
441
  }
442
+ chosen = chosen || (!hintIncludes ? (focused || firstVisible) : null);
443
+ if (!chosen) {
444
+ emit({ ok: false, error: 'visible EditText not found' });
445
+ return;
446
+ }
447
+ var JavaString = Java.use('java.lang.String');
448
+ var BufferType = Java.use('android.widget.TextView$BufferType');
449
+ var SpannableStringBuilder = Java.use('android.text.SpannableStringBuilder');
450
+ chosen.view.requestFocus();
451
+ try { chosen.view.performClick(); } catch (_) { }
452
+ try {
453
+ var imm = InputMethodManager.getInstance.overload().call(InputMethodManager);
454
+ imm.showSoftInput(chosen.view, 0);
455
+ } catch (_) { }
456
+ chosen.view.setText(SpannableStringBuilder.$new(JavaString.$new(targetText)), BufferType.EDITABLE.value);
457
+ chosen.view.setSelection(targetText.length);
458
+ var actualText = toJsString(chosen.view.getText());
459
+ emit({
460
+ ok: true,
461
+ text: actualText,
462
+ hint: toJsString(chosen.view.getHint()),
463
+ className: chosen.className,
464
+ bounds: chosen.bounds
465
+ });
466
+ } catch (error) {
467
+ emit({ ok: false, error: String(error), stack: String(error.stack || '') });
218
468
  }
219
- emit({ ok: true, target: target, matches: matches });
220
- } catch (error) {
221
- emit({ ok: false, target: ${JSON.stringify(String(text || ''))}, error: String(error), stack: String(error.stack || '') });
222
- }
469
+ });
223
470
  });
224
471
  `, {
225
- label: options.label || 'find-visible-text-bounds',
472
+ label: options.label || 'set-visible-input-text',
226
473
  packageName: options.packageName || ctx.packageName,
227
474
  timeoutMs: options.timeoutMs || 7000
228
475
  });
229
476
  if (!event.ok) {
230
477
  throw new CrawlerError({
231
- message: `automation_failed: findVisibleTextBounds failed ${event.error || text}`,
478
+ message: `automation_failed: setVisibleInputText failed ${event.error || ''}`.trim(),
232
479
  code: Code.AutomationFailed,
233
480
  context: event
234
481
  });
235
482
  }
236
- return event.matches || [];
483
+ return event;
237
484
  }
238
485
 
239
486
  /**
240
- * 列出当前 App 内部可见输入框。
487
+ * 触发当前输入框的 IME action。
241
488
  *
242
- * 微信这类 App 往往不给系统 uiautomator 暴露节点,但 App 进程内仍能看到
243
- * EditText。业务方可以用 hint/text/bounds 挑选输入框,再交给 Device.click
244
- * setFocusedInputText 使用。
489
+ * 这对应软键盘右下角的“搜索 / 完成 / 发送”等动作,
490
+ * 不是硬件 ENTER。很多 App 会区分这两种输入:
491
+ * `adb shell input keyevent ENTER` 只发送按键事件;
492
+ * 软键盘按钮会调用当前 InputConnection.performEditorAction(actionCode)。
245
493
  */
246
- export async function findVisibleInputs(ctx, options = {}) {
494
+ export async function performEditorAction(ctx, action = 'search', options = {}) {
495
+ const actionCode = editorActionCode(action);
247
496
  const event = await runScript(ctx, `
248
497
  Java.perform(function () {
249
498
  function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
250
- function rectOf(view) {
251
- var Rect = Java.use('android.graphics.Rect');
252
- var rect = Rect.$new();
253
- try { view.getGlobalVisibleRect(rect); } catch (_) { }
254
- return { left: rect.left.value, top: rect.top.value, right: rect.right.value, bottom: rect.bottom.value };
255
- }
256
- function walk(view, out, depth) {
257
- if (!view || depth > ${Number(options.maxDepth || 20)} || out.length > ${Number(options.maxNodes || 3000)}) return;
258
- out.push(view);
259
- try {
260
- var ViewGroup = Java.use('android.view.ViewGroup');
261
- var group = Java.cast(view, ViewGroup);
262
- for (var i = 0; i < group.getChildCount(); i++) walk(group.getChildAt(i), out, depth + 1);
263
- } catch (_) { }
264
- }
265
- function activeActivities() {
266
- var ActivityThread = Java.use('android.app.ActivityThread');
267
- var Activity = Java.use('android.app.Activity');
268
- var ArrayMap = Java.use('android.util.ArrayMap');
269
- var thread = ActivityThread.currentActivityThread();
270
- var field = thread.getClass().getDeclaredField('mActivities');
271
- field.setAccessible(true);
272
- var map = Java.cast(field.get(thread), ArrayMap);
273
- var out = [];
274
- for (var i = 0; i < map.size(); i++) {
275
- var record = map.valueAt(i);
276
- var recordClass = record.getClass();
277
- var activityField = recordClass.getDeclaredField('activity');
278
- var pausedField = recordClass.getDeclaredField('paused');
279
- activityField.setAccessible(true);
280
- pausedField.setAccessible(true);
281
- if (!pausedField.getBoolean(record)) out.push(Java.cast(activityField.get(record), Activity));
282
- }
283
- return out;
284
- }
285
499
  Java.scheduleOnMainThread(function () {
286
500
  try {
287
- var EditText = Java.use('android.widget.EditText');
288
- var inputs = [];
289
- var activities = activeActivities();
290
- for (var a = 0; a < activities.length; a++) {
291
- var views = [];
292
- walk(activities[a].getWindow().getDecorView(), views, 0);
293
- for (var i = 0; i < views.length; i++) {
294
- var view = views[i];
295
- if (!view.isShown()) continue;
296
- try {
297
- var editText = Java.cast(view, EditText);
298
- var rect = rectOf(view);
299
- if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
300
- inputs.push({
301
- text: String(editText.getText().toString()),
302
- hint: String(editText.getHint() ? editText.getHint().toString() : ''),
303
- className: String(view.getClass().getName()),
304
- idName: (function () { try { return String(view.getResources().getResourceName(view.getId())); } catch (_) { return ''; } })(),
305
- focused: Boolean(view.hasFocus()),
306
- bounds: rect
307
- });
308
- } catch (_) { }
309
- }
501
+ var InputMethodManager = Java.use('android.view.inputmethod.InputMethodManager');
502
+ var IInputConnectionWrapper = Java.use('com.android.internal.view.IInputConnectionWrapper');
503
+ var imm = InputMethodManager.getInstance.overload().call(InputMethodManager);
504
+ var field = imm.getClass().getDeclaredField('mServedInputConnectionWrapper');
505
+ field.setAccessible(true);
506
+ var rawConnection = field.get(imm);
507
+ if (!rawConnection) {
508
+ emit({ ok: false, error: 'mServedInputConnectionWrapper is empty', actionCode: ${actionCode} });
509
+ return;
310
510
  }
311
- emit({ ok: true, inputs: inputs });
511
+ var connection = Java.cast(rawConnection, IInputConnectionWrapper);
512
+ connection.performEditorAction.overload('int').call(connection, ${actionCode});
513
+ emit({
514
+ ok: true,
515
+ actionCode: ${actionCode},
516
+ className: String(connection.getClass().getName())
517
+ });
312
518
  } catch (error) {
313
- emit({ ok: false, error: String(error), stack: String(error.stack || '') });
519
+ emit({ ok: false, actionCode: ${actionCode}, error: String(error), stack: String(error.stack || '') });
314
520
  }
315
521
  });
316
522
  });
317
523
  `, {
318
- label: options.label || 'find-visible-inputs',
524
+ label: options.label || 'perform-editor-action',
319
525
  packageName: options.packageName || ctx.packageName,
320
526
  timeoutMs: options.timeoutMs || 7000
321
527
  });
322
528
  if (!event.ok) {
323
529
  throw new CrawlerError({
324
- message: `automation_failed: findVisibleInputs failed ${event.error || ''}`.trim(),
530
+ message: `automation_failed: performEditorAction failed ${event.error || ''}`.trim(),
531
+ code: Code.AutomationFailed,
532
+ context: event
533
+ });
534
+ }
535
+ return event;
536
+ }
537
+
538
+ /**
539
+ * 在当前可见 WebView 中执行一段 JavaScript,并返回 evaluateJavascript 的回调值。
540
+ *
541
+ * 这是 Android 版的 `page.evaluate()` 基础能力:
542
+ * - toolkit 只负责找到当前 Activity 里的可见 WebView;
543
+ * - 业务方传入要执行的 JS 字符串;
544
+ * - JS 返回值保持 Android WebView 的原始 callback 形态,业务方自行解析。
545
+ *
546
+ * 注意:这里不关心具体页面选择器,也不内置任何微信/抖音等业务逻辑。
547
+ */
548
+ export async function evaluateVisibleWebView(ctx, script, options = {}) {
549
+ const event = await evaluateVisibleWebViews(ctx, script, {
550
+ ...options,
551
+ maxCandidates: 1
552
+ });
553
+ const first = event.candidates?.[0];
554
+ if (!first) {
555
+ throw new CrawlerError({
556
+ message: 'automation_failed: evaluateVisibleWebView failed visible WebView not found',
325
557
  code: Code.AutomationFailed,
326
558
  context: event
327
559
  });
328
560
  }
329
- return event.inputs || [];
561
+ return {
562
+ ok: true,
563
+ value: first.value,
564
+ className: first.className,
565
+ castClassName: first.castClassName,
566
+ url: first.url,
567
+ index: first.index
568
+ };
330
569
  }
331
570
 
332
571
  /**
333
- * 给当前获得焦点的 EditText 设置文本,并触发常规输入事件。
572
+ * 在所有匹配的可见 WebView 中执行同一段 JavaScript。
334
573
  *
335
- * 这比 adb input text 更适合中文,也比直接 setText 更稳:它先 focus 目标
336
- * EditText,再用 Editable.replace 更新文本,最后把光标放到末尾。
574
+ * 这是 `evaluateVisibleWebView` 的批量版本,用来处理一个 Activity 内存在多个
575
+ * WebView 的场景。toolkit 只返回每个候选的原始 evaluateJavascript callback;
576
+ * 业务方根据自己的页面结构判断哪个候选才是目标页面。
337
577
  */
338
- export async function setFocusedInputText(ctx, text, options = {}) {
578
+ export async function evaluateVisibleWebViews(ctx, script, options = {}) {
579
+ const webViewClasses = normalizeWebViewClasses(options.webViewClasses);
580
+ const urlIncludes = String(options.urlIncludes || '').trim();
581
+ const maxCandidates = Math.max(1, Number(options.maxCandidates || 12));
582
+
339
583
  const event = await runScript(ctx, `
340
584
  Java.perform(function () {
341
585
  function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
342
- function rectOf(view) {
343
- var Rect = Java.use('android.graphics.Rect');
344
- var rect = Rect.$new();
345
- try { view.getGlobalVisibleRect(rect); } catch (_) { }
346
- return { left: rect.left.value, top: rect.top.value, right: rect.right.value, bottom: rect.bottom.value };
586
+ var JavaObject = Java.use('java.lang.Object');
587
+ function toJsString(value) {
588
+ if (value === null || value === undefined) return '';
589
+ try { return Java.cast(value, JavaObject).toString() + ''; } catch (_) { }
590
+ try { return value.toString.overload().call(value) + ''; } catch (_) { }
591
+ try { return value.toString() + ''; } catch (_) { }
592
+ return '';
347
593
  }
348
594
  function walk(view, out, depth) {
349
- if (!view || depth > ${Number(options.maxDepth || 20)} || out.length > ${Number(options.maxNodes || 3000)}) return;
595
+ if (!view || depth > ${Number(options.maxDepth || 25)} || out.length > ${Number(options.maxNodes || 5000)}) return;
350
596
  out.push(view);
351
597
  try {
352
598
  var ViewGroup = Java.use('android.view.ViewGroup');
@@ -374,60 +620,128 @@ Java.perform(function () {
374
620
  }
375
621
  return out;
376
622
  }
623
+ function useClass(name, loader) {
624
+ try { return Java.use(name); } catch (_) { }
625
+ try { return Java.ClassFactory.get(loader).use(name); } catch (_) { }
626
+ return null;
627
+ }
628
+ function castWebView(view, actualClassName) {
629
+ var configuredNames = ${JSON.stringify(webViewClasses)};
630
+ var names = [actualClassName];
631
+ for (var n = 0; n < configuredNames.length; n++) {
632
+ if (names.indexOf(configuredNames[n]) < 0) names.push(configuredNames[n]);
633
+ }
634
+ var loader = null;
635
+ try { loader = view.getClass().getClassLoader(); } catch (_) { }
636
+ for (var i = 0; i < names.length; i++) {
637
+ try {
638
+ var Klass = useClass(names[i], loader);
639
+ if (!Klass) continue;
640
+ var casted = Java.cast(view, Klass);
641
+ if (!casted.evaluateJavascript) continue;
642
+ return { view: casted, className: names[i] };
643
+ } catch (_) { }
644
+ }
645
+ return null;
646
+ }
647
+ var Callback = Java.registerClass({
648
+ name: 'com.android.toolkit.JsValueCallback' + Date.now(),
649
+ implements: [Java.use('android.webkit.ValueCallback')],
650
+ methods: {
651
+ onReceiveValue: function (value) {
652
+ var item = pending[currentIndex] || {};
653
+ results.push({
654
+ index: item.index,
655
+ className: item.className,
656
+ castClassName: item.castClassName,
657
+ shown: item.shown,
658
+ url: item.url,
659
+ value: toJsString(value)
660
+ });
661
+ currentIndex += 1;
662
+ evaluateNext();
663
+ }
664
+ }
665
+ });
666
+ var pending = [];
667
+ var results = [];
668
+ var currentIndex = 0;
669
+ function evaluateNext() {
670
+ if (currentIndex >= pending.length) {
671
+ emit({ ok: true, candidates: results });
672
+ return;
673
+ }
674
+ try {
675
+ pending[currentIndex].view.evaluateJavascript(${JSON.stringify(String(script || ''))}, Callback.$new());
676
+ } catch (error) {
677
+ var item = pending[currentIndex] || {};
678
+ results.push({
679
+ index: item.index,
680
+ className: item.className,
681
+ castClassName: item.castClassName,
682
+ shown: item.shown,
683
+ url: item.url,
684
+ error: String(error)
685
+ });
686
+ currentIndex += 1;
687
+ evaluateNext();
688
+ }
689
+ }
377
690
  Java.scheduleOnMainThread(function () {
378
691
  try {
379
- var EditText = Java.use('android.widget.EditText');
380
- var targetText = ${JSON.stringify(String(text || ''))};
381
692
  var activities = activeActivities();
382
- var chosen = null;
383
- var fallback = null;
693
+ var candidates = [];
694
+ var expectedUrl = ${JSON.stringify(urlIncludes)};
695
+ var maxCandidates = ${maxCandidates};
384
696
  for (var a = 0; a < activities.length; a++) {
385
697
  var views = [];
386
698
  walk(activities[a].getWindow().getDecorView(), views, 0);
387
699
  for (var i = 0; i < views.length; i++) {
388
- var view = views[i];
389
- if (!view.isShown()) continue;
390
- try {
391
- var editText = Java.cast(view, EditText);
392
- var rect = rectOf(view);
393
- if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
394
- var item = { view: editText, bounds: rect, text: String(editText.getText().toString()), hint: String(editText.getHint() ? editText.getHint().toString() : ''), className: String(view.getClass().getName()), focused: Boolean(view.hasFocus()) };
395
- if (item.focused) chosen = item;
396
- if (!fallback) fallback = item;
397
- } catch (_) { }
700
+ var className = toJsString(views[i].getClass().getName());
701
+ var normalizedClassName = className.toLowerCase();
702
+ if (normalizedClassName.indexOf('webview') < 0) continue;
703
+ var casted = castWebView(views[i], className);
704
+ if (!casted) continue;
705
+ var shown = false;
706
+ try { shown = Boolean(views[i].isShown()); } catch (_) { }
707
+ var url = '';
708
+ try { url = toJsString(casted.view.getUrl()); } catch (_) { }
709
+ var meta = { index: candidates.length, className: className, castClassName: casted.className, shown: shown, url: url };
710
+ candidates.push(meta);
711
+ if (shown && (!expectedUrl || url.indexOf(expectedUrl) >= 0)) {
712
+ pending.push({
713
+ index: meta.index,
714
+ className: meta.className,
715
+ castClassName: meta.castClassName,
716
+ shown: meta.shown,
717
+ url: meta.url,
718
+ view: casted.view
719
+ });
720
+ if (pending.length >= maxCandidates) break;
721
+ }
398
722
  }
723
+ if (pending.length >= maxCandidates) break;
399
724
  }
400
- chosen = chosen || fallback;
401
- if (!chosen) {
402
- emit({ ok: false, error: 'visible EditText not found' });
725
+ if (pending.length === 0) {
726
+ emit({ ok: false, error: 'visible WebView not found', candidates: candidates.slice(0, 20) });
403
727
  return;
404
728
  }
405
- var JavaString = Java.use('java.lang.String');
406
- var BufferType = Java.use('android.widget.TextView$BufferType');
407
- var SpannableStringBuilder = Java.use('android.text.SpannableStringBuilder');
408
- chosen.view.requestFocus();
409
- chosen.view.setText(SpannableStringBuilder.$new(JavaString.$new(targetText)), BufferType.EDITABLE.value);
410
- chosen.view.setSelection(targetText.length);
411
- emit({
412
- ok: true,
413
- text: targetText,
414
- hint: String(chosen.view.getHint() ? chosen.view.getHint().toString() : ''),
415
- className: chosen.className,
416
- bounds: chosen.bounds
417
- });
729
+ evaluateNext();
418
730
  } catch (error) {
419
731
  emit({ ok: false, error: String(error), stack: String(error.stack || '') });
420
732
  }
421
733
  });
422
734
  });
423
735
  `, {
424
- label: options.label || 'set-focused-input-text',
736
+ label: options.label || 'evaluate-visible-webview',
425
737
  packageName: options.packageName || ctx.packageName,
426
- timeoutMs: options.timeoutMs || 7000
738
+ timeoutMs: options.timeoutMs || 12000,
739
+ fridaTimeoutSeconds: options.fridaTimeoutSeconds || 8,
740
+ maxLines: options.maxLines || 80
427
741
  });
428
742
  if (!event.ok) {
429
743
  throw new CrawlerError({
430
- message: `automation_failed: setFocusedInputText failed ${event.error || ''}`.trim(),
744
+ message: `automation_failed: evaluateVisibleWebViews failed ${event.error || ''}`.trim(),
431
745
  code: Code.AutomationFailed,
432
746
  context: event
433
747
  });
@@ -435,6 +749,25 @@ Java.perform(function () {
435
749
  return event;
436
750
  }
437
751
 
752
+ function normalizeWebViewClasses(value) {
753
+ if (Array.isArray(value) && value.length > 0) {
754
+ const clean = value.map((item) => String(item || '').trim()).filter(Boolean);
755
+ if (clean.length > 0) return clean;
756
+ }
757
+ return [
758
+ 'android.webkit.WebView',
759
+ 'com.tencent.xweb.WebView',
760
+ 'com.tencent.xweb.pinus.sdk.WebView',
761
+ 'com.tencent.xweb.pinus.PSWebview',
762
+ 'com.tencent.mm.ui.widget.MMWebView'
763
+ ];
764
+ }
765
+
766
+ function normalizeStringArray(value) {
767
+ if (!Array.isArray(value)) return [];
768
+ return value.map((item) => String(item ?? '')).filter((item) => item);
769
+ }
770
+
438
771
  /**
439
772
  * 找到目标包名当前可 attach 的 pid。
440
773
  *
@@ -442,6 +775,7 @@ Java.perform(function () {
442
775
  * 再从 `ps -A` 兜底扫描,避免 Frida attach 到已经退出的进程。
443
776
  */
444
777
  export async function resolvePid(ctx, packageName = ctx.packageName) {
778
+ Logger.debug('resolve pid start', {serial: ctx.serial, packageName});
445
779
  const pidof = await adbShell(ctx, ['pidof', packageName]).catch(() => '');
446
780
  const pidCandidates = String(pidof || '').trim().split(/\s+/).filter(Boolean);
447
781
  const ps = await adbShell(ctx, ['ps', '-A', '-o', 'USER,PID,PPID,STAT,NAME']).catch(() => '');
@@ -450,18 +784,41 @@ export async function resolvePid(ctx, packageName = ctx.packageName) {
450
784
  for (let i = pidCandidates.length - 1; i >= 0; i -= 1) {
451
785
  const pid = pidCandidates[i];
452
786
  const row = psRows.find((item) => item.pid === pid);
453
- if (row && row.name === packageName && !row.stat.includes('Z')) return pid;
787
+ if (row && row.name === packageName && !row.stat.includes('Z')) {
788
+ Logger.debug('resolve pid done', {serial: ctx.serial, packageName, pid});
789
+ return pid;
790
+ }
454
791
  }
455
792
  for (const row of psRows) {
456
- if (row.name === packageName && !row.stat.includes('Z')) return row.pid;
793
+ if (row.name === packageName && !row.stat.includes('Z')) {
794
+ Logger.debug('resolve pid done', {serial: ctx.serial, packageName, pid: row.pid});
795
+ return row.pid;
796
+ }
457
797
  }
458
798
 
459
799
  const fallbackPs = ps || await adbShell(ctx, ['ps', '-A']);
460
800
  for (const line of String(fallbackPs || '').split('\n')) {
461
801
  const parts = line.trim().split(/\s+/);
462
- if (parts.length >= 2 && parts.at(-1) === packageName && !parts.some((part) => /^Z/.test(part))) return parts[1];
802
+ if (parts.length >= 2 && parts.at(-1) === packageName && !parts.some((part) => /^Z/.test(part))) {
803
+ Logger.debug('resolve pid done', {serial: ctx.serial, packageName, pid: parts[1]});
804
+ return parts[1];
805
+ }
463
806
  }
464
- throw new Error(`process not found: ${packageName}`);
807
+ throw new CrawlerError({
808
+ message: `frida_unavailable: process not found: ${packageName}`,
809
+ code: Code.FridaUnavailable,
810
+ context: {packageName}
811
+ });
812
+ }
813
+
814
+ async function listFridaServerRows(ctx) {
815
+ const ps = await adbShell(ctx, ['ps', '-A', '-o', 'USER,PID,PPID,STAT,NAME']).catch(() => '');
816
+ return parsePsRows(ps).filter((row) => row.name === 'frida-server');
817
+ }
818
+
819
+ async function canUseSu(ctx) {
820
+ const out = await adbShell(ctx, ['su', '-c', 'id'], {timeoutMs: 5000}).catch(() => '');
821
+ return String(out || '').includes('uid=0');
465
822
  }
466
823
 
467
824
  function startFridaLineRecorder(command, args, options = {}) {
@@ -473,6 +830,8 @@ function startFridaLineRecorder(command, args, options = {}) {
473
830
  let rawBuffer = '';
474
831
  let stopped = false;
475
832
  let exited = false;
833
+ let stdoutClosed = false;
834
+ let stderrClosed = false;
476
835
  let exitCode = null;
477
836
  let exitSignal = null;
478
837
  let fatalError = null;
@@ -498,6 +857,12 @@ function startFridaLineRecorder(command, args, options = {}) {
498
857
 
499
858
  child.stdout.on('data', append);
500
859
  child.stderr.on('data', append);
860
+ child.stdout.on('close', () => {
861
+ stdoutClosed = true;
862
+ });
863
+ child.stderr.on('close', () => {
864
+ stderrClosed = true;
865
+ });
501
866
  child.on('error', (error) => {
502
867
  lines.push(`${label} error: ${error.message || String(error)}`);
503
868
  fatalError = makeFridaUnavailableError(label, error.message || String(error), lines);
@@ -531,6 +896,20 @@ function startFridaLineRecorder(command, args, options = {}) {
531
896
  return snapshot();
532
897
  };
533
898
 
899
+ const waitForEvent = async (predicate, timeoutMs) => {
900
+ const deadline = Date.now() + Math.max(0, timeoutMs);
901
+ while (Date.now() <= deadline) {
902
+ if (fatalError) throw fatalError;
903
+ const matched = events.find((event) => predicate(event));
904
+ if (matched) return matched;
905
+ if (exited) {
906
+ throw makeFridaUnavailableError(label, `Frida exited while waiting event code=${exitCode} signal=${exitSignal || ''}`.trim(), lines);
907
+ }
908
+ await new Promise((resolve) => setTimeout(resolve, 120));
909
+ }
910
+ throw makeFridaUnavailableError(label, `Frida event wait timeout: ${options.readyEventLabel || 'event'}`, lines);
911
+ };
912
+
534
913
  const waitForExit = async (timeoutMs) => {
535
914
  const deadline = Date.now() + Math.max(0, timeoutMs);
536
915
  while (Date.now() <= deadline) {
@@ -553,6 +932,18 @@ function startFridaLineRecorder(command, args, options = {}) {
553
932
  resolve();
554
933
  });
555
934
  });
935
+ if (!exited) child.kill('SIGKILL');
936
+ if (!exited) {
937
+ await new Promise((resolve) => {
938
+ const timer = setTimeout(resolve, 1000);
939
+ child.once('exit', () => {
940
+ clearTimeout(timer);
941
+ resolve();
942
+ });
943
+ });
944
+ }
945
+ await waitForOutputFlush();
946
+ if (rawBuffer) append('\n');
556
947
  const result = snapshot();
557
948
  if (options.outputPath) {
558
949
  fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
@@ -563,11 +954,20 @@ function startFridaLineRecorder(command, args, options = {}) {
563
954
 
564
955
  return {
565
956
  pid: child.pid,
957
+ waitForEvent,
566
958
  waitForStartup,
567
959
  waitForExit,
568
960
  stop,
569
961
  snapshot
570
962
  };
963
+
964
+ async function waitForOutputFlush(timeoutMs = 500) {
965
+ const deadline = Date.now() + timeoutMs;
966
+ while (Date.now() < deadline) {
967
+ if (stdoutClosed && stderrClosed) return;
968
+ await new Promise((resolve) => setTimeout(resolve, 25));
969
+ }
970
+ }
571
971
  }
572
972
 
573
973
  function makeFridaUnavailableError(label, message, lines) {
@@ -631,16 +1031,38 @@ function assertCommandOrFile(value, label) {
631
1031
  if (looksLikePath && !fs.existsSync(command)) throw new Error(`${label} not found: ${command}`);
632
1032
  }
633
1033
 
1034
+ function editorActionCode(action) {
1035
+ if (typeof action === 'number' && Number.isFinite(action)) return Math.trunc(action);
1036
+ const clean = String(action || '').trim().toLowerCase();
1037
+ const codes = {
1038
+ none: 1,
1039
+ go: 2,
1040
+ search: 3,
1041
+ send: 4,
1042
+ next: 5,
1043
+ done: 6,
1044
+ previous: 7
1045
+ };
1046
+ return codes[clean] || codes.search;
1047
+ }
1048
+
634
1049
  function safeFilePart(value) {
635
1050
  return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '-');
636
1051
  }
637
1052
 
1053
+ function shellQuote(value) {
1054
+ return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
1055
+ }
1056
+
638
1057
  export const Frida = {
639
1058
  startWebViewEventRecorder,
640
1059
  runScript,
1060
+ querySQLite,
1061
+ ensureFridaServer,
641
1062
  startActivityInsideApp,
642
- findVisibleTextBounds,
643
- findVisibleInputs,
644
- setFocusedInputText,
1063
+ setVisibleInputText,
1064
+ performEditorAction,
1065
+ evaluateVisibleWebView,
1066
+ evaluateVisibleWebViews,
645
1067
  resolvePid
646
1068
  };