@skrillex1224/android-toolkit 0.1.1 → 0.1.7

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.
@@ -4,7 +4,7 @@ import {spawn} from 'node:child_process';
4
4
  import {Code} from './constants.js';
5
5
  import {adbShell} from './device.js';
6
6
  import {CrawlerError} from './errors.js';
7
- import {Logger} from './logger.js';
7
+ import {Logger, sleep} from './logger.js';
8
8
 
9
9
  /**
10
10
  * 启动一个持续运行的 Frida WebView 事件记录器。
@@ -20,11 +20,19 @@ import {Logger} 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,10 +63,20 @@ export async function startWebViewEventRecorder(ctx, config = {}, options = {})
54
63
  * 收集输出和错误显式化。
55
64
  */
56
65
  export async function runScript(ctx, source, options = {}) {
66
+ const startedAt = Date.now();
67
+ const label = options.label || 'script';
68
+ await ensureFridaServer(ctx);
57
69
  assertFridaReady(ctx, ctx.fridaWebViewEventAgentScript, 'frida webview event agent script');
58
70
  const marker = options.marker || 'ANDROID_TOOLKIT_SCRIPT_JSON ';
59
- const scriptPath = writeTempScript(ctx, source, options.label || 'script');
71
+ const scriptPath = writeTempScript(ctx, source, label);
60
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
+ });
61
80
  const recorder = startFridaLineRecorder(ctx.fridaPath, [
62
81
  '-q',
63
82
  '-t',
@@ -78,13 +97,216 @@ export async function runScript(ctx, source, options = {}) {
78
97
  const result = await recorder.stop();
79
98
  const scriptEvents = result.events.filter((event) => event?.type !== 'parse_error');
80
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
+ });
81
105
  throw new CrawlerError({
82
- message: `frida_script_failed: ${options.label || 'script'} did not emit ${marker.trim()}`,
106
+ message: `frida_script_failed: ${label} did not emit ${marker.trim()}`,
83
107
  code: Code.FridaUnavailable,
84
108
  context: {lines: result.lines.slice(-40)}
85
109
  });
86
110
  }
87
- 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
+ });
88
310
  }
89
311
 
90
312
  /**
@@ -116,7 +338,8 @@ Java.perform(function () {
116
338
  `, {
117
339
  label: options.label || 'start-activity-inside-app',
118
340
  packageName: options.packageName || ctx.packageName,
119
- timeoutMs: options.timeoutMs || 7000
341
+ timeoutMs: options.timeoutMs || 12000,
342
+ fridaTimeoutSeconds: options.fridaTimeoutSeconds || 10
120
343
  });
121
344
  if (!event.ok) {
122
345
  throw new CrawlerError({
@@ -129,16 +352,27 @@ Java.perform(function () {
129
352
  }
130
353
 
131
354
  /**
132
- * 在当前 App 内部 View 树里查找可见文本节点,返回真实屏幕 bounds。
355
+ * 查找当前 App 内部可见 EditText,并直接写入文本。
133
356
  *
134
- * 这不是 OCR,也不依赖系统 uiautomator 无障碍树;它在 App 进程内遍历
135
- * Activity DecorView。微信这类 App 经常不给 uiautomator 暴露节点,但
136
- * 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 打成乱码。
137
363
  */
138
- export async function findVisibleTextBounds(ctx, text, options = {}) {
364
+ export async function setVisibleInputText(ctx, text, options = {}) {
139
365
  const event = await runScript(ctx, `
140
366
  Java.perform(function () {
141
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
+ }
142
376
  function rectOf(view) {
143
377
  var Rect = Java.use('android.graphics.Rect');
144
378
  var rect = Rect.$new();
@@ -174,163 +408,191 @@ Java.perform(function () {
174
408
  }
175
409
  return out;
176
410
  }
177
- try {
178
- var target = ${JSON.stringify(String(text || ''))};
179
- var exact = ${options.exact === false ? 'false' : 'true'};
180
- var TextView = Java.use('android.widget.TextView');
181
- var matches = [];
182
- var activities = activeActivities();
183
- for (var a = 0; a < activities.length; a++) {
184
- var views = [];
185
- walk(activities[a].getWindow().getDecorView(), views, 0);
186
- for (var i = 0; i < views.length; i++) {
187
- var view = views[i];
188
- if (!view.isShown()) continue;
189
- var value = '';
190
- try { value = String(Java.cast(view, TextView).getText().toString()); } catch (_) { continue; }
191
- if (exact ? value === target : value.indexOf(target) >= 0) {
192
- var rect = rectOf(view);
193
- if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
194
- matches.push({
195
- text: value,
196
- className: String(view.getClass().getName()),
197
- idName: (function () { try { return String(view.getResources().getResourceName(view.getId())); } catch (_) { return ''; } })(),
198
- bounds: rect
199
- });
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 (_) { }
200
439
  }
440
+ if (chosen) break;
441
+ }
442
+ chosen = chosen || (!hintIncludes ? (focused || firstVisible) : null);
443
+ if (!chosen) {
444
+ emit({ ok: false, error: 'visible EditText not found' });
445
+ return;
201
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 || '') });
202
468
  }
203
- emit({ ok: true, target: target, matches: matches });
204
- } catch (error) {
205
- emit({ ok: false, target: ${JSON.stringify(String(text || ''))}, error: String(error), stack: String(error.stack || '') });
206
- }
469
+ });
207
470
  });
208
471
  `, {
209
- label: options.label || 'find-visible-text-bounds',
472
+ label: options.label || 'set-visible-input-text',
210
473
  packageName: options.packageName || ctx.packageName,
211
474
  timeoutMs: options.timeoutMs || 7000
212
475
  });
213
476
  if (!event.ok) {
214
477
  throw new CrawlerError({
215
- message: `automation_failed: findVisibleTextBounds failed ${event.error || text}`,
478
+ message: `automation_failed: setVisibleInputText failed ${event.error || ''}`.trim(),
216
479
  code: Code.AutomationFailed,
217
480
  context: event
218
481
  });
219
482
  }
220
- return event.matches || [];
483
+ return event;
221
484
  }
222
485
 
223
486
  /**
224
- * 列出当前 App 内部可见输入框。
487
+ * 触发当前输入框的 IME action。
225
488
  *
226
- * 微信这类 App 往往不给系统 uiautomator 暴露节点,但 App 进程内仍能看到
227
- * EditText。业务方可以用 hint/text/bounds 挑选输入框,再交给 Device.click
228
- * setFocusedInputText 使用。
489
+ * 这对应软键盘右下角的“搜索 / 完成 / 发送”等动作,
490
+ * 不是硬件 ENTER。很多 App 会区分这两种输入:
491
+ * `adb shell input keyevent ENTER` 只发送按键事件;
492
+ * 软键盘按钮会调用当前 InputConnection.performEditorAction(actionCode)。
229
493
  */
230
- export async function findVisibleInputs(ctx, options = {}) {
494
+ export async function performEditorAction(ctx, action = 'search', options = {}) {
495
+ const actionCode = editorActionCode(action);
231
496
  const event = await runScript(ctx, `
232
497
  Java.perform(function () {
233
498
  function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
234
- function rectOf(view) {
235
- var Rect = Java.use('android.graphics.Rect');
236
- var rect = Rect.$new();
237
- try { view.getGlobalVisibleRect(rect); } catch (_) { }
238
- return { left: rect.left.value, top: rect.top.value, right: rect.right.value, bottom: rect.bottom.value };
239
- }
240
- function walk(view, out, depth) {
241
- if (!view || depth > ${Number(options.maxDepth || 20)} || out.length > ${Number(options.maxNodes || 3000)}) return;
242
- out.push(view);
243
- try {
244
- var ViewGroup = Java.use('android.view.ViewGroup');
245
- var group = Java.cast(view, ViewGroup);
246
- for (var i = 0; i < group.getChildCount(); i++) walk(group.getChildAt(i), out, depth + 1);
247
- } catch (_) { }
248
- }
249
- function activeActivities() {
250
- var ActivityThread = Java.use('android.app.ActivityThread');
251
- var Activity = Java.use('android.app.Activity');
252
- var ArrayMap = Java.use('android.util.ArrayMap');
253
- var thread = ActivityThread.currentActivityThread();
254
- var field = thread.getClass().getDeclaredField('mActivities');
255
- field.setAccessible(true);
256
- var map = Java.cast(field.get(thread), ArrayMap);
257
- var out = [];
258
- for (var i = 0; i < map.size(); i++) {
259
- var record = map.valueAt(i);
260
- var recordClass = record.getClass();
261
- var activityField = recordClass.getDeclaredField('activity');
262
- var pausedField = recordClass.getDeclaredField('paused');
263
- activityField.setAccessible(true);
264
- pausedField.setAccessible(true);
265
- if (!pausedField.getBoolean(record)) out.push(Java.cast(activityField.get(record), Activity));
266
- }
267
- return out;
268
- }
269
499
  Java.scheduleOnMainThread(function () {
270
500
  try {
271
- var EditText = Java.use('android.widget.EditText');
272
- var inputs = [];
273
- var activities = activeActivities();
274
- for (var a = 0; a < activities.length; a++) {
275
- var views = [];
276
- walk(activities[a].getWindow().getDecorView(), views, 0);
277
- for (var i = 0; i < views.length; i++) {
278
- var view = views[i];
279
- if (!view.isShown()) continue;
280
- try {
281
- var editText = Java.cast(view, EditText);
282
- var rect = rectOf(view);
283
- if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
284
- inputs.push({
285
- text: String(editText.getText().toString()),
286
- hint: String(editText.getHint() ? editText.getHint().toString() : ''),
287
- className: String(view.getClass().getName()),
288
- idName: (function () { try { return String(view.getResources().getResourceName(view.getId())); } catch (_) { return ''; } })(),
289
- focused: Boolean(view.hasFocus()),
290
- bounds: rect
291
- });
292
- } catch (_) { }
293
- }
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;
294
510
  }
295
- 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
+ });
296
518
  } catch (error) {
297
- emit({ ok: false, error: String(error), stack: String(error.stack || '') });
519
+ emit({ ok: false, actionCode: ${actionCode}, error: String(error), stack: String(error.stack || '') });
298
520
  }
299
521
  });
300
522
  });
301
523
  `, {
302
- label: options.label || 'find-visible-inputs',
524
+ label: options.label || 'perform-editor-action',
303
525
  packageName: options.packageName || ctx.packageName,
304
526
  timeoutMs: options.timeoutMs || 7000
305
527
  });
306
528
  if (!event.ok) {
307
529
  throw new CrawlerError({
308
- message: `automation_failed: findVisibleInputs failed ${event.error || ''}`.trim(),
530
+ message: `automation_failed: performEditorAction failed ${event.error || ''}`.trim(),
309
531
  code: Code.AutomationFailed,
310
532
  context: event
311
533
  });
312
534
  }
313
- return event.inputs || [];
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',
557
+ code: Code.AutomationFailed,
558
+ context: event
559
+ });
560
+ }
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
+ };
314
569
  }
315
570
 
316
571
  /**
317
- * 给当前获得焦点的 EditText 设置文本,并触发常规输入事件。
572
+ * 在所有匹配的可见 WebView 中执行同一段 JavaScript。
318
573
  *
319
- * 这比 adb input text 更适合中文,也比直接 setText 更稳:它先 focus 目标
320
- * EditText,再用 Editable.replace 更新文本,最后把光标放到末尾。
574
+ * 这是 `evaluateVisibleWebView` 的批量版本,用来处理一个 Activity 内存在多个
575
+ * WebView 的场景。toolkit 只返回每个候选的原始 evaluateJavascript callback;
576
+ * 业务方根据自己的页面结构判断哪个候选才是目标页面。
321
577
  */
322
- 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
+
323
583
  const event = await runScript(ctx, `
324
584
  Java.perform(function () {
325
585
  function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
326
- function rectOf(view) {
327
- var Rect = Java.use('android.graphics.Rect');
328
- var rect = Rect.$new();
329
- try { view.getGlobalVisibleRect(rect); } catch (_) { }
330
- 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 '';
331
593
  }
332
594
  function walk(view, out, depth) {
333
- 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;
334
596
  out.push(view);
335
597
  try {
336
598
  var ViewGroup = Java.use('android.view.ViewGroup');
@@ -358,60 +620,128 @@ Java.perform(function () {
358
620
  }
359
621
  return out;
360
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
+ }
361
690
  Java.scheduleOnMainThread(function () {
362
691
  try {
363
- var EditText = Java.use('android.widget.EditText');
364
- var targetText = ${JSON.stringify(String(text || ''))};
365
692
  var activities = activeActivities();
366
- var chosen = null;
367
- var fallback = null;
693
+ var candidates = [];
694
+ var expectedUrl = ${JSON.stringify(urlIncludes)};
695
+ var maxCandidates = ${maxCandidates};
368
696
  for (var a = 0; a < activities.length; a++) {
369
697
  var views = [];
370
698
  walk(activities[a].getWindow().getDecorView(), views, 0);
371
699
  for (var i = 0; i < views.length; i++) {
372
- var view = views[i];
373
- if (!view.isShown()) continue;
374
- try {
375
- var editText = Java.cast(view, EditText);
376
- var rect = rectOf(view);
377
- if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
378
- 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()) };
379
- if (item.focused) chosen = item;
380
- if (!fallback) fallback = item;
381
- } 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
+ }
382
722
  }
723
+ if (pending.length >= maxCandidates) break;
383
724
  }
384
- chosen = chosen || fallback;
385
- if (!chosen) {
386
- 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) });
387
727
  return;
388
728
  }
389
- var JavaString = Java.use('java.lang.String');
390
- var BufferType = Java.use('android.widget.TextView$BufferType');
391
- var SpannableStringBuilder = Java.use('android.text.SpannableStringBuilder');
392
- chosen.view.requestFocus();
393
- chosen.view.setText(SpannableStringBuilder.$new(JavaString.$new(targetText)), BufferType.EDITABLE.value);
394
- chosen.view.setSelection(targetText.length);
395
- emit({
396
- ok: true,
397
- text: targetText,
398
- hint: String(chosen.view.getHint() ? chosen.view.getHint().toString() : ''),
399
- className: chosen.className,
400
- bounds: chosen.bounds
401
- });
729
+ evaluateNext();
402
730
  } catch (error) {
403
731
  emit({ ok: false, error: String(error), stack: String(error.stack || '') });
404
732
  }
405
733
  });
406
734
  });
407
735
  `, {
408
- label: options.label || 'set-focused-input-text',
736
+ label: options.label || 'evaluate-visible-webview',
409
737
  packageName: options.packageName || ctx.packageName,
410
- timeoutMs: options.timeoutMs || 7000
738
+ timeoutMs: options.timeoutMs || 12000,
739
+ fridaTimeoutSeconds: options.fridaTimeoutSeconds || 8,
740
+ maxLines: options.maxLines || 80
411
741
  });
412
742
  if (!event.ok) {
413
743
  throw new CrawlerError({
414
- message: `automation_failed: setFocusedInputText failed ${event.error || ''}`.trim(),
744
+ message: `automation_failed: evaluateVisibleWebViews failed ${event.error || ''}`.trim(),
415
745
  code: Code.AutomationFailed,
416
746
  context: event
417
747
  });
@@ -419,6 +749,25 @@ Java.perform(function () {
419
749
  return event;
420
750
  }
421
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
+
422
771
  /**
423
772
  * 找到目标包名当前可 attach 的 pid。
424
773
  *
@@ -426,6 +775,7 @@ Java.perform(function () {
426
775
  * 再从 `ps -A` 兜底扫描,避免 Frida attach 到已经退出的进程。
427
776
  */
428
777
  export async function resolvePid(ctx, packageName = ctx.packageName) {
778
+ Logger.debug('resolve pid start', {serial: ctx.serial, packageName});
429
779
  const pidof = await adbShell(ctx, ['pidof', packageName]).catch(() => '');
430
780
  const pidCandidates = String(pidof || '').trim().split(/\s+/).filter(Boolean);
431
781
  const ps = await adbShell(ctx, ['ps', '-A', '-o', 'USER,PID,PPID,STAT,NAME']).catch(() => '');
@@ -434,18 +784,41 @@ export async function resolvePid(ctx, packageName = ctx.packageName) {
434
784
  for (let i = pidCandidates.length - 1; i >= 0; i -= 1) {
435
785
  const pid = pidCandidates[i];
436
786
  const row = psRows.find((item) => item.pid === pid);
437
- 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
+ }
438
791
  }
439
792
  for (const row of psRows) {
440
- 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
+ }
441
797
  }
442
798
 
443
799
  const fallbackPs = ps || await adbShell(ctx, ['ps', '-A']);
444
800
  for (const line of String(fallbackPs || '').split('\n')) {
445
801
  const parts = line.trim().split(/\s+/);
446
- 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
+ }
447
806
  }
448
- 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');
449
822
  }
450
823
 
451
824
  function startFridaLineRecorder(command, args, options = {}) {
@@ -457,6 +830,8 @@ function startFridaLineRecorder(command, args, options = {}) {
457
830
  let rawBuffer = '';
458
831
  let stopped = false;
459
832
  let exited = false;
833
+ let stdoutClosed = false;
834
+ let stderrClosed = false;
460
835
  let exitCode = null;
461
836
  let exitSignal = null;
462
837
  let fatalError = null;
@@ -482,6 +857,12 @@ function startFridaLineRecorder(command, args, options = {}) {
482
857
 
483
858
  child.stdout.on('data', append);
484
859
  child.stderr.on('data', append);
860
+ child.stdout.on('close', () => {
861
+ stdoutClosed = true;
862
+ });
863
+ child.stderr.on('close', () => {
864
+ stderrClosed = true;
865
+ });
485
866
  child.on('error', (error) => {
486
867
  lines.push(`${label} error: ${error.message || String(error)}`);
487
868
  fatalError = makeFridaUnavailableError(label, error.message || String(error), lines);
@@ -515,6 +896,20 @@ function startFridaLineRecorder(command, args, options = {}) {
515
896
  return snapshot();
516
897
  };
517
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
+
518
913
  const waitForExit = async (timeoutMs) => {
519
914
  const deadline = Date.now() + Math.max(0, timeoutMs);
520
915
  while (Date.now() <= deadline) {
@@ -537,6 +932,18 @@ function startFridaLineRecorder(command, args, options = {}) {
537
932
  resolve();
538
933
  });
539
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');
540
947
  const result = snapshot();
541
948
  if (options.outputPath) {
542
949
  fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
@@ -547,11 +954,20 @@ function startFridaLineRecorder(command, args, options = {}) {
547
954
 
548
955
  return {
549
956
  pid: child.pid,
957
+ waitForEvent,
550
958
  waitForStartup,
551
959
  waitForExit,
552
960
  stop,
553
961
  snapshot
554
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
+ }
555
971
  }
556
972
 
557
973
  function makeFridaUnavailableError(label, message, lines) {
@@ -615,16 +1031,38 @@ function assertCommandOrFile(value, label) {
615
1031
  if (looksLikePath && !fs.existsSync(command)) throw new Error(`${label} not found: ${command}`);
616
1032
  }
617
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
+
618
1049
  function safeFilePart(value) {
619
1050
  return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '-');
620
1051
  }
621
1052
 
1053
+ function shellQuote(value) {
1054
+ return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
1055
+ }
1056
+
622
1057
  export const Frida = {
623
1058
  startWebViewEventRecorder,
624
1059
  runScript,
1060
+ querySQLite,
1061
+ ensureFridaServer,
625
1062
  startActivityInsideApp,
626
- findVisibleTextBounds,
627
- findVisibleInputs,
628
- setFocusedInputText,
1063
+ setVisibleInputText,
1064
+ performEditorAction,
1065
+ evaluateVisibleWebView,
1066
+ evaluateVisibleWebViews,
629
1067
  resolvePid
630
1068
  };