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