@skrillex1224/android-toolkit 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/frida-client.js +220 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skrillex1224/android-toolkit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -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 事件记录器。
@@ -54,6 +54,22 @@ export async function startWebViewEventRecorder(ctx, config = {}, options = {})
54
54
  * 收集输出和错误显式化。
55
55
  */
56
56
  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 = {}) {
57
73
  assertFridaReady(ctx, ctx.fridaWebViewEventAgentScript, 'frida webview event agent script');
58
74
  const marker = options.marker || 'ANDROID_TOOLKIT_SCRIPT_JSON ';
59
75
  const scriptPath = writeTempScript(ctx, source, options.label || 'script');
@@ -74,7 +90,7 @@ export async function runScript(ctx, source, options = {}) {
74
90
  maxLines: Number(options.maxLines || 1200),
75
91
  outputPath: options.outputPath || ''
76
92
  });
77
- await recorder.waitForExit(Number(options.timeoutMs || 7000));
93
+ await recorder.waitForExit(Number(options.timeoutMs || 10000));
78
94
  const result = await recorder.stop();
79
95
  const scriptEvents = result.events.filter((event) => event?.type !== 'parse_error');
80
96
  if (scriptEvents.length === 0) {
@@ -187,7 +203,7 @@ Java.perform(function () {
187
203
  var view = views[i];
188
204
  if (!view.isShown()) continue;
189
205
  var value = '';
190
- try { value = String(Java.cast(view, TextView).getText()); } catch (_) { continue; }
206
+ try { value = String(Java.cast(view, TextView).getText().toString()); } catch (_) { continue; }
191
207
  if (exact ? value === target : value.indexOf(target) >= 0) {
192
208
  var rect = rectOf(view);
193
209
  if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
@@ -220,6 +236,205 @@ Java.perform(function () {
220
236
  return event.matches || [];
221
237
  }
222
238
 
239
+ /**
240
+ * 列出当前 App 内部可见输入框。
241
+ *
242
+ * 微信这类 App 往往不给系统 uiautomator 暴露节点,但 App 进程内仍能看到
243
+ * EditText。业务方可以用 hint/text/bounds 挑选输入框,再交给 Device.click
244
+ * 或 setFocusedInputText 使用。
245
+ */
246
+ export async function findVisibleInputs(ctx, options = {}) {
247
+ const event = await runScript(ctx, `
248
+ Java.perform(function () {
249
+ 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
+ Java.scheduleOnMainThread(function () {
286
+ 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
+ }
310
+ }
311
+ emit({ ok: true, inputs: inputs });
312
+ } catch (error) {
313
+ emit({ ok: false, error: String(error), stack: String(error.stack || '') });
314
+ }
315
+ });
316
+ });
317
+ `, {
318
+ label: options.label || 'find-visible-inputs',
319
+ packageName: options.packageName || ctx.packageName,
320
+ timeoutMs: options.timeoutMs || 7000
321
+ });
322
+ if (!event.ok) {
323
+ throw new CrawlerError({
324
+ message: `automation_failed: findVisibleInputs failed ${event.error || ''}`.trim(),
325
+ code: Code.AutomationFailed,
326
+ context: event
327
+ });
328
+ }
329
+ return event.inputs || [];
330
+ }
331
+
332
+ /**
333
+ * 给当前获得焦点的 EditText 设置文本,并触发常规输入事件。
334
+ *
335
+ * 这比 adb input text 更适合中文,也比直接 setText 更稳:它先 focus 目标
336
+ * EditText,再用 Editable.replace 更新文本,最后把光标放到末尾。
337
+ */
338
+ export async function setFocusedInputText(ctx, text, options = {}) {
339
+ const event = await runScript(ctx, `
340
+ Java.perform(function () {
341
+ 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 };
347
+ }
348
+ function walk(view, out, depth) {
349
+ if (!view || depth > ${Number(options.maxDepth || 20)} || out.length > ${Number(options.maxNodes || 3000)}) return;
350
+ out.push(view);
351
+ try {
352
+ var ViewGroup = Java.use('android.view.ViewGroup');
353
+ var group = Java.cast(view, ViewGroup);
354
+ for (var i = 0; i < group.getChildCount(); i++) walk(group.getChildAt(i), out, depth + 1);
355
+ } catch (_) { }
356
+ }
357
+ function activeActivities() {
358
+ var ActivityThread = Java.use('android.app.ActivityThread');
359
+ var Activity = Java.use('android.app.Activity');
360
+ var ArrayMap = Java.use('android.util.ArrayMap');
361
+ var thread = ActivityThread.currentActivityThread();
362
+ var field = thread.getClass().getDeclaredField('mActivities');
363
+ field.setAccessible(true);
364
+ var map = Java.cast(field.get(thread), ArrayMap);
365
+ var out = [];
366
+ for (var i = 0; i < map.size(); i++) {
367
+ var record = map.valueAt(i);
368
+ var recordClass = record.getClass();
369
+ var activityField = recordClass.getDeclaredField('activity');
370
+ var pausedField = recordClass.getDeclaredField('paused');
371
+ activityField.setAccessible(true);
372
+ pausedField.setAccessible(true);
373
+ if (!pausedField.getBoolean(record)) out.push(Java.cast(activityField.get(record), Activity));
374
+ }
375
+ return out;
376
+ }
377
+ Java.scheduleOnMainThread(function () {
378
+ try {
379
+ var EditText = Java.use('android.widget.EditText');
380
+ var targetText = ${JSON.stringify(String(text || ''))};
381
+ var activities = activeActivities();
382
+ var chosen = null;
383
+ var fallback = null;
384
+ for (var a = 0; a < activities.length; a++) {
385
+ var views = [];
386
+ walk(activities[a].getWindow().getDecorView(), views, 0);
387
+ 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 (_) { }
398
+ }
399
+ }
400
+ chosen = chosen || fallback;
401
+ if (!chosen) {
402
+ emit({ ok: false, error: 'visible EditText not found' });
403
+ return;
404
+ }
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
+ });
418
+ } catch (error) {
419
+ emit({ ok: false, error: String(error), stack: String(error.stack || '') });
420
+ }
421
+ });
422
+ });
423
+ `, {
424
+ label: options.label || 'set-focused-input-text',
425
+ packageName: options.packageName || ctx.packageName,
426
+ timeoutMs: options.timeoutMs || 7000
427
+ });
428
+ if (!event.ok) {
429
+ throw new CrawlerError({
430
+ message: `automation_failed: setFocusedInputText failed ${event.error || ''}`.trim(),
431
+ code: Code.AutomationFailed,
432
+ context: event
433
+ });
434
+ }
435
+ return event;
436
+ }
437
+
223
438
  /**
224
439
  * 找到目标包名当前可 attach 的 pid。
225
440
  *
@@ -425,5 +640,7 @@ export const Frida = {
425
640
  runScript,
426
641
  startActivityInsideApp,
427
642
  findVisibleTextBounds,
643
+ findVisibleInputs,
644
+ setFocusedInputText,
428
645
  resolvePid
429
646
  };