@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.
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/apify-kit.js +66 -41
- package/src/constants.js +2 -1
- package/src/context.js +0 -9
- package/src/device.js +111 -69
- package/src/frida-client.js +597 -159
- package/src/internals/frida/webview_event_agent.js +4 -0
- package/src/launch.js +25 -9
- package/src/logger.js +60 -2
package/src/frida-client.js
CHANGED
|
@@ -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(
|
|
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,
|
|
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: ${
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
*
|
|
355
|
+
* 查找当前 App 内部可见 EditText,并直接写入文本。
|
|
133
356
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
var
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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 || '
|
|
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:
|
|
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
|
|
483
|
+
return event;
|
|
221
484
|
}
|
|
222
485
|
|
|
223
486
|
/**
|
|
224
|
-
*
|
|
487
|
+
* 触发当前输入框的 IME action。
|
|
225
488
|
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
489
|
+
* 这对应软键盘右下角的“搜索 / 完成 / 发送”等动作,
|
|
490
|
+
* 不是硬件 ENTER。很多 App 会区分这两种输入:
|
|
491
|
+
* `adb shell input keyevent ENTER` 只发送按键事件;
|
|
492
|
+
* 软键盘按钮会调用当前 InputConnection.performEditorAction(actionCode)。
|
|
229
493
|
*/
|
|
230
|
-
export async function
|
|
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
|
|
272
|
-
var
|
|
273
|
-
var
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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 || '
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
572
|
+
* 在所有匹配的可见 WebView 中执行同一段 JavaScript。
|
|
318
573
|
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
574
|
+
* 这是 `evaluateVisibleWebView` 的批量版本,用来处理一个 Activity 内存在多个
|
|
575
|
+
* WebView 的场景。toolkit 只返回每个候选的原始 evaluateJavascript callback;
|
|
576
|
+
* 业务方根据自己的页面结构判断哪个候选才是目标页面。
|
|
321
577
|
*/
|
|
322
|
-
export async function
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
try {
|
|
330
|
-
|
|
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 ||
|
|
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
|
|
367
|
-
var
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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 || '
|
|
736
|
+
label: options.label || 'evaluate-visible-webview',
|
|
409
737
|
packageName: options.packageName || ctx.packageName,
|
|
410
|
-
timeoutMs: options.timeoutMs ||
|
|
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:
|
|
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'))
|
|
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'))
|
|
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)))
|
|
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
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
1063
|
+
setVisibleInputText,
|
|
1064
|
+
performEditorAction,
|
|
1065
|
+
evaluateVisibleWebView,
|
|
1066
|
+
evaluateVisibleWebViews,
|
|
629
1067
|
resolvePid
|
|
630
1068
|
};
|