@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/device.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import zlib from 'node:zlib';
|
|
2
3
|
import {execFile} from 'node:child_process';
|
|
3
4
|
import {promisify} from 'node:util';
|
|
4
5
|
|
|
6
|
+
import {Code} from './constants.js';
|
|
7
|
+
import {CrawlerError} from './errors.js';
|
|
5
8
|
import {Logger, sleep} from './logger.js';
|
|
6
9
|
|
|
7
10
|
const execFileAsync = promisify(execFile);
|
|
@@ -14,35 +17,52 @@ export async function adbShell(ctx, args, options = {}) {
|
|
|
14
17
|
|
|
15
18
|
export async function adbExec(ctx, args, options = {}) {
|
|
16
19
|
// 执行原始 adb 命令;当云手机/TCP ADB 临时 offline 时,自动 adb connect 后重放一次。
|
|
20
|
+
const startedAt = Date.now();
|
|
21
|
+
Logger.debug('adb exec', {serial: ctx.serial, args: compactArgs(args), timeoutMs: Number(options.timeoutMs || 15000)});
|
|
17
22
|
try {
|
|
18
23
|
const {stdout} = await execFileAsync(ctx.adbPath, args, {
|
|
19
24
|
timeout: Number(options.timeoutMs || 15000),
|
|
20
25
|
maxBuffer: Number(options.maxBuffer || 12 * 1024 * 1024),
|
|
21
26
|
encoding: options.encoding || 'utf8'
|
|
22
27
|
});
|
|
28
|
+
Logger.debug('adb exec done', {serial: ctx.serial, duration: Logger.duration(startedAt)});
|
|
23
29
|
return stdout;
|
|
24
30
|
} catch (error) {
|
|
25
31
|
// ADB over TCP 的云手机/云真机场景经常临时 offline。
|
|
26
32
|
// 这里统一做一次 adb connect + 原命令重放,业务层不需要关心连接抖动。
|
|
27
|
-
if (!isAdbOfflineError(error) || !ctx.serial)
|
|
33
|
+
if (!isAdbOfflineError(error) || !ctx.serial) {
|
|
34
|
+
if (isAdbUnavailableError(error)) throw makeAdbUnavailableError(ctx, args, error);
|
|
35
|
+
Logger.warn('adb exec failed', {serial: ctx.serial, args: compactArgs(args), message: error?.message || String(error)});
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
Logger.warn('adb offline, reconnecting', {serial: ctx.serial, args: compactArgs(args)});
|
|
28
39
|
await execFileAsync(ctx.adbPath, ['connect', ctx.serial], {timeout: 15000}).catch(() => { });
|
|
29
40
|
await sleep(1200);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
try {
|
|
42
|
+
const {stdout} = await execFileAsync(ctx.adbPath, args, {
|
|
43
|
+
timeout: Number(options.timeoutMs || 15000),
|
|
44
|
+
maxBuffer: Number(options.maxBuffer || 12 * 1024 * 1024),
|
|
45
|
+
encoding: options.encoding || 'utf8'
|
|
46
|
+
});
|
|
47
|
+
Logger.debug('adb exec retry done', {serial: ctx.serial, duration: Logger.duration(startedAt)});
|
|
48
|
+
return stdout;
|
|
49
|
+
} catch (retryError) {
|
|
50
|
+
if (isAdbUnavailableError(retryError)) throw makeAdbUnavailableError(ctx, args, retryError);
|
|
51
|
+
Logger.warn('adb exec retry failed', {serial: ctx.serial, args: compactArgs(args), message: retryError?.message || String(retryError)});
|
|
52
|
+
throw retryError;
|
|
53
|
+
}
|
|
36
54
|
}
|
|
37
55
|
}
|
|
38
56
|
|
|
39
57
|
export async function forceStopApp(ctx, packageName = ctx.packageName) {
|
|
40
58
|
// 强制关闭 App,用于每条任务开始前清理上一次页面状态。
|
|
59
|
+
Logger.info('force stop app', {serial: ctx.serial, packageName});
|
|
41
60
|
await adbShell(ctx, ['am', 'force-stop', packageName]).catch(() => { });
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
export async function startActivity(ctx, component, args = []) {
|
|
45
64
|
// 启动指定 Android Activity,component 形如 `com.tencent.mm/.ui.LauncherUI`。
|
|
65
|
+
Logger.info('start activity', {serial: ctx.serial, component});
|
|
46
66
|
await adbShell(ctx, ['am', 'start', '-n', component, ...args]);
|
|
47
67
|
await sleep(1800);
|
|
48
68
|
}
|
|
@@ -55,11 +75,6 @@ export async function startLauncher(ctx, packageName = ctx.packageName, launcher
|
|
|
55
75
|
]);
|
|
56
76
|
}
|
|
57
77
|
|
|
58
|
-
export async function launchPackageByMonkey(ctx, packageName = ctx.packageName) {
|
|
59
|
-
// launcher 启动失败时的兜底:让 Android 自己找包的默认入口。
|
|
60
|
-
await adbShell(ctx, ['monkey', '-p', packageName, '1']);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
78
|
export async function waitForForeground(ctx, packageName = ctx.packageName, timeoutMs = 6000) {
|
|
64
79
|
// 轮询前台 Activity,直到目标包名进入前台或超时。
|
|
65
80
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -81,19 +96,24 @@ export async function screenSize(ctx) {
|
|
|
81
96
|
};
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
export function
|
|
85
|
-
|
|
86
|
-
|
|
99
|
+
export async function screenDensity(ctx) {
|
|
100
|
+
const out = await adbShell(ctx, ['wm', 'density']).catch(() => '');
|
|
101
|
+
const match = /(\d+)/.exec(String(out || ''));
|
|
102
|
+
return match ? Number(match[1]) : 0;
|
|
87
103
|
}
|
|
88
104
|
|
|
89
|
-
export function
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
export async function overrideScreenSize(ctx, width, height) {
|
|
106
|
+
const safeWidth = Math.max(1, Math.round(Number(width) || 0));
|
|
107
|
+
const safeHeight = Math.max(1, Math.round(Number(height) || 0));
|
|
108
|
+
await adbShell(ctx, ['wm', 'size', `${safeWidth}x${safeHeight}`], {timeoutMs: 15000});
|
|
92
109
|
}
|
|
93
110
|
|
|
94
|
-
export async function
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
export async function resetScreenSize(ctx) {
|
|
112
|
+
await adbShell(ctx, ['wm', 'size', 'reset'], {timeoutMs: 15000});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function resetScreenDensity(ctx) {
|
|
116
|
+
await adbShell(ctx, ['wm', 'density', 'reset'], {timeoutMs: 15000});
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
export async function tapAbsolute(ctx, x, y) {
|
|
@@ -116,11 +136,6 @@ export async function click(ctx, pointOrX, y) {
|
|
|
116
136
|
return tapAbsolute(ctx, pointOrX, y);
|
|
117
137
|
}
|
|
118
138
|
|
|
119
|
-
export async function swipeRatio(ctx, fromX, fromY, toX, toY, durationMs = 500) {
|
|
120
|
-
// 使用比例坐标滑动,适合滚动列表、收起弹层等通用手势。
|
|
121
|
-
await adbInput(ctx, ['swipe', px(ctx, fromX), py(ctx, fromY), px(ctx, toX), py(ctx, toY), String(durationMs)]);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
139
|
export async function move(ctx, from, to, durationMs = 500) {
|
|
125
140
|
// ADB 没有鼠标 hover 概念,这里的 move 表示拖动/滑动手势。
|
|
126
141
|
// from/to 使用屏幕绝对坐标,便于和截图/其他定位结果直接衔接。
|
|
@@ -139,35 +154,66 @@ export async function pressBack(ctx) {
|
|
|
139
154
|
await adbInput(ctx, ['keyevent', 'BACK']).catch(() => { });
|
|
140
155
|
}
|
|
141
156
|
|
|
142
|
-
export async function
|
|
143
|
-
//
|
|
144
|
-
|
|
157
|
+
export async function dismissPermissionDialog(ctx) {
|
|
158
|
+
// Android 运行时权限弹窗会把原 App 的 Activity 压在下面。
|
|
159
|
+
// 这里只处理系统 permission controller:点击底部“拒绝”按钮区域,避免采集任务卡死。
|
|
160
|
+
const focused = await focusedActivity(ctx).catch(() => '');
|
|
161
|
+
if (!String(focused || '').includes('GrantPermissionsActivity')) return false;
|
|
162
|
+
const size = await screenSize(ctx);
|
|
163
|
+
await tapAbsolute(ctx, size.width * 0.50, size.height * 0.63).catch(() => { });
|
|
164
|
+
await sleep(1000);
|
|
165
|
+
return true;
|
|
145
166
|
}
|
|
146
167
|
|
|
147
168
|
export async function wakeAndUnlock(ctx) {
|
|
148
169
|
// 唤醒并解锁设备。真机/云手机在息屏时 dumpsys 可能仍保留上一个 App,
|
|
149
170
|
// 业务层会误判前台 Activity,所以每条原生任务开始前都应该先恢复可交互屏幕。
|
|
171
|
+
Logger.info('wake and unlock', {serial: ctx.serial});
|
|
150
172
|
await adbInput(ctx, ['keyevent', 'KEYCODE_WAKEUP']).catch(() => { });
|
|
151
173
|
await sleep(500);
|
|
152
174
|
await adbInput(ctx, ['keyevent', 'MENU']).catch(() => { });
|
|
153
175
|
await sleep(500);
|
|
154
|
-
await
|
|
176
|
+
await swipeScreenRatio(ctx, 0.50, 0.82, 0.50, 0.25, 350).catch(() => { });
|
|
155
177
|
await sleep(800);
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
export async function focusedActivity(ctx) {
|
|
181
|
+
// 优先从 activity manager 读取 resumed activity。`dumpsys window` 在微信 WebView
|
|
182
|
+
// 场景里可能同时保留旧窗口和新窗口,直接读 window focus 容易误判。
|
|
183
|
+
const resumed = await resumedActivity(ctx).catch(() => '');
|
|
184
|
+
if (resumed) return resumed;
|
|
185
|
+
|
|
159
186
|
// 从 dumpsys window 中提取当前前台 Activity,用于替代 OCR 做页面状态判断。
|
|
160
187
|
const out = await adbShell(ctx, ['dumpsys', 'window']);
|
|
161
188
|
let fallback = '';
|
|
162
|
-
let
|
|
189
|
+
let currentFocus = '';
|
|
190
|
+
let appWindowFocus = '';
|
|
163
191
|
for (const line of String(out || '').split('\n')) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
192
|
+
const trimmed = line.trim();
|
|
193
|
+
if (!trimmed.includes('mCurrentFocus') && !trimmed.includes('mFocusedApp')) continue;
|
|
194
|
+
if (!fallback) fallback = trimmed;
|
|
195
|
+
if (trimmed.includes('=null')) continue;
|
|
196
|
+
// dumpsys window 里会同时出现当前窗口和历史 token 行。
|
|
197
|
+
// 只信真实窗口焦点和 AppWindowToken,避免把旧 LauncherUI Token 当成当前页。
|
|
198
|
+
if (trimmed.includes('mCurrentFocus=Window')) currentFocus = trimmed;
|
|
199
|
+
else if (trimmed.includes('mFocusedApp=AppWindowToken')) appWindowFocus = trimmed;
|
|
169
200
|
}
|
|
170
|
-
return
|
|
201
|
+
return currentFocus || appWindowFocus || fallback || String(out || '').trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function resumedActivity(ctx) {
|
|
205
|
+
const out = await adbShell(ctx, ['dumpsys', 'activity', 'activities'], {
|
|
206
|
+
timeoutMs: 15000,
|
|
207
|
+
maxBuffer: 12 * 1024 * 1024
|
|
208
|
+
});
|
|
209
|
+
let fallback = '';
|
|
210
|
+
for (const line of String(out || '').split('\n')) {
|
|
211
|
+
const trimmed = line.trim();
|
|
212
|
+
if (!trimmed.includes('ActivityRecord') || !trimmed.includes('/')) continue;
|
|
213
|
+
if (trimmed.includes('mTopResumedActivity') || trimmed.includes('topResumedActivity')) return trimmed;
|
|
214
|
+
if (trimmed.includes('mResumedActivity')) fallback = trimmed;
|
|
215
|
+
}
|
|
216
|
+
return fallback;
|
|
171
217
|
}
|
|
172
218
|
|
|
173
219
|
export function activityIncludes(focused, fragment) {
|
|
@@ -177,10 +223,15 @@ export function activityIncludes(focused, fragment) {
|
|
|
177
223
|
|
|
178
224
|
export async function waitForActivity(ctx, predicate, timeoutMs = 15000, intervalMs = 700) {
|
|
179
225
|
// 等待前台 Activity 满足调用方 predicate。
|
|
226
|
+
const startedAt = Date.now();
|
|
227
|
+
Logger.info('wait activity start', {serial: ctx.serial, timeoutMs, intervalMs});
|
|
180
228
|
const deadline = Date.now() + timeoutMs;
|
|
181
229
|
while (Date.now() < deadline) {
|
|
182
230
|
const focused = await focusedActivity(ctx).catch(() => '');
|
|
183
|
-
if (predicate(focused))
|
|
231
|
+
if (predicate(focused)) {
|
|
232
|
+
Logger.info('wait activity matched', {serial: ctx.serial, duration: Logger.duration(startedAt), focused: summarizeText(focused, 160)});
|
|
233
|
+
return focused;
|
|
234
|
+
}
|
|
184
235
|
await sleep(intervalMs);
|
|
185
236
|
}
|
|
186
237
|
throw new Error(`waitForActivity timeout: focused=${await focusedActivity(ctx).catch(() => '')}`);
|
|
@@ -188,25 +239,71 @@ export async function waitForActivity(ctx, predicate, timeoutMs = 15000, interva
|
|
|
188
239
|
|
|
189
240
|
export async function screenshotBase64(ctx) {
|
|
190
241
|
// 获取当前屏幕 PNG,并转成 base64 写入 dataset artifact。
|
|
191
|
-
const stdout = await
|
|
192
|
-
timeoutMs: 15000,
|
|
193
|
-
encoding: 'buffer',
|
|
194
|
-
maxBuffer: 8 * 1024 * 1024
|
|
195
|
-
});
|
|
242
|
+
const stdout = await screenshotPng(ctx);
|
|
196
243
|
return Buffer.from(stdout).toString('base64');
|
|
197
244
|
}
|
|
198
245
|
|
|
246
|
+
export async function longScreenshotBase64(ctx, options = {}) {
|
|
247
|
+
const startedAt = Date.now();
|
|
248
|
+
const size = await screenSize(ctx).catch(() => ({width: 720, height: 1280}));
|
|
249
|
+
const captureCount = Math.max(1, Math.min(8, Number(options.captureCount || 4)));
|
|
250
|
+
const topCrop = clampInt(Number(options.topCrop ?? Math.floor(size.height * 0.12)), 0, size.height - 1);
|
|
251
|
+
const bottomCrop = clampInt(Number(options.bottomCrop ?? Math.floor(size.height * 0.18)), 0, size.height - topCrop - 1);
|
|
252
|
+
const swipeFromY = Number(options.swipeFromY || 0.78);
|
|
253
|
+
const swipeToY = Number(options.swipeToY || 0.28);
|
|
254
|
+
const swipeDurationMs = Number(options.swipeDurationMs || 650);
|
|
255
|
+
const settleMs = Number(options.settleMs || 900);
|
|
256
|
+
const scrollToTopFirst = Boolean(options.scrollToTopFirst);
|
|
257
|
+
const scrollToTopSwipes = Math.max(0, Math.min(8, Number(options.scrollToTopSwipes || 4)));
|
|
258
|
+
const pngs = [];
|
|
259
|
+
|
|
260
|
+
Logger.info('long screenshot start', {
|
|
261
|
+
serial: ctx.serial,
|
|
262
|
+
captureCount,
|
|
263
|
+
topCrop,
|
|
264
|
+
bottomCrop,
|
|
265
|
+
width: size.width,
|
|
266
|
+
height: size.height
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (scrollToTopFirst) {
|
|
270
|
+
for (let i = 0; i < scrollToTopSwipes; i += 1) {
|
|
271
|
+
await swipeScreenRatio(ctx, 0.50, swipeToY, 0.50, swipeFromY, swipeDurationMs).catch(() => { });
|
|
272
|
+
await sleep(Math.max(350, Math.floor(settleMs * 0.6)));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < captureCount; i += 1) {
|
|
277
|
+
const png = await screenshotPng(ctx);
|
|
278
|
+
pngs.push(png);
|
|
279
|
+
if (i < captureCount - 1) {
|
|
280
|
+
await swipeScreenRatio(ctx, 0.50, swipeFromY, 0.50, swipeToY, swipeDurationMs).catch(() => { });
|
|
281
|
+
await sleep(settleMs);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const stitched = stitchVerticalPngs(pngs, {
|
|
286
|
+
topCrop,
|
|
287
|
+
bottomCrop,
|
|
288
|
+
maxOutputHeight: Math.max(size.height, Number(options.maxOutputHeight || size.height * captureCount)),
|
|
289
|
+
dedupeAdjacent: options.dedupeAdjacent !== false
|
|
290
|
+
});
|
|
291
|
+
Logger.info('long screenshot done', {
|
|
292
|
+
serial: ctx.serial,
|
|
293
|
+
duration: Logger.duration(startedAt),
|
|
294
|
+
bytes: stitched.length,
|
|
295
|
+
captureCount: pngs.length
|
|
296
|
+
});
|
|
297
|
+
return stitched.toString('base64');
|
|
298
|
+
}
|
|
299
|
+
|
|
199
300
|
export async function saveDebugScreenshot(ctx, name) {
|
|
200
301
|
// 保存调试截图到 /tmp,主要用于本地定位点击点和页面状态。
|
|
201
302
|
if (!ctx.serial) return '';
|
|
202
303
|
const safeRunId = safeFilePart(ctx.runId || 'manual');
|
|
203
304
|
const safeName = safeFilePart(name || 'screenshot');
|
|
204
305
|
const out = `/tmp/android-toolkit-${safeRunId}-${safeName}.png`;
|
|
205
|
-
const stdout = await
|
|
206
|
-
timeoutMs: 15000,
|
|
207
|
-
encoding: 'buffer',
|
|
208
|
-
maxBuffer: 8 * 1024 * 1024
|
|
209
|
-
});
|
|
306
|
+
const stdout = await screenshotPng(ctx);
|
|
210
307
|
fs.writeFileSync(out, stdout);
|
|
211
308
|
Logger.info(`debug screenshot saved ${out}`);
|
|
212
309
|
return out;
|
|
@@ -249,20 +346,309 @@ export async function type(ctx, text) {
|
|
|
249
346
|
}
|
|
250
347
|
|
|
251
348
|
async function adbInput(ctx, args, options = {}) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
349
|
+
await adbShell(ctx, ['input', ...args], options);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function screenshotPng(ctx) {
|
|
353
|
+
return adbExec(ctx, ['-s', ctx.serial, 'exec-out', 'screencap', '-p'], {
|
|
354
|
+
timeoutMs: 15000,
|
|
355
|
+
encoding: 'buffer',
|
|
356
|
+
maxBuffer: 24 * 1024 * 1024
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function stitchVerticalPngs(buffers, options = {}) {
|
|
361
|
+
const frames = buffers.map((buffer) => decodePng(buffer));
|
|
362
|
+
if (frames.length === 0) throw new Error('longScreenshotBase64 requires at least one screenshot');
|
|
363
|
+
const width = frames[0].width;
|
|
364
|
+
const colorType = frames[0].colorType;
|
|
365
|
+
const bitDepth = frames[0].bitDepth;
|
|
366
|
+
const bytesPerPixel = pngBytesPerPixel(colorType, bitDepth);
|
|
367
|
+
if (bytesPerPixel !== 4) {
|
|
368
|
+
throw new Error(`longScreenshotBase64 only supports RGBA PNG screenshots, got colorType=${colorType} bitDepth=${bitDepth}`);
|
|
369
|
+
}
|
|
370
|
+
for (const frame of frames) {
|
|
371
|
+
if (frame.width !== width || frame.colorType !== colorType || frame.bitDepth !== bitDepth) {
|
|
372
|
+
throw new Error('longScreenshotBase64 screenshots have inconsistent PNG formats');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const uniqueFrames = [];
|
|
377
|
+
for (const frame of frames) {
|
|
378
|
+
const previous = uniqueFrames[uniqueFrames.length - 1];
|
|
379
|
+
if (previous && isNearDuplicateFrame(previous, frame, width, bytesPerPixel)) {
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
uniqueFrames.push(frame);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const parts = [];
|
|
386
|
+
let totalHeight = 0;
|
|
387
|
+
const topCrop = Math.max(0, Number(options.topCrop || 0));
|
|
388
|
+
const bottomCrop = Math.max(0, Number(options.bottomCrop || 0));
|
|
389
|
+
const maxOutputHeight = Math.max(1, Number(options.maxOutputHeight || Number.MAX_SAFE_INTEGER));
|
|
390
|
+
for (let i = 0; i < uniqueFrames.length; i += 1) {
|
|
391
|
+
const frame = uniqueFrames[i];
|
|
392
|
+
const cropTop = i === 0 ? 0 : Math.min(topCrop, frame.height - 1);
|
|
393
|
+
const cropBottom = i === uniqueFrames.length - 1 ? 0 : Math.min(bottomCrop, frame.height - cropTop - 1);
|
|
394
|
+
let slice = cropFrame(frame, cropTop, cropBottom, bytesPerPixel);
|
|
395
|
+
if (options.dedupeAdjacent !== false && parts.length > 0) {
|
|
396
|
+
slice = removeDuplicateOverlap(parts[parts.length - 1], slice, width, bytesPerPixel);
|
|
397
|
+
}
|
|
398
|
+
if (slice.height <= 0) continue;
|
|
399
|
+
if (totalHeight + slice.height > maxOutputHeight) {
|
|
400
|
+
slice = cropSliceHeight(slice, maxOutputHeight - totalHeight, width, bytesPerPixel);
|
|
263
401
|
}
|
|
402
|
+
if (slice.height <= 0) break;
|
|
403
|
+
parts.push(slice);
|
|
404
|
+
totalHeight += slice.height;
|
|
405
|
+
if (totalHeight >= maxOutputHeight) break;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const rgba = Buffer.alloc(width * totalHeight * bytesPerPixel);
|
|
409
|
+
let y = 0;
|
|
410
|
+
for (const part of parts) {
|
|
411
|
+
part.rgba.copy(rgba, y * width * bytesPerPixel);
|
|
412
|
+
y += part.height;
|
|
413
|
+
}
|
|
414
|
+
return encodePng({width, height: totalHeight, rgba});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isNearDuplicateFrame(previous, current, width, bytesPerPixel) {
|
|
418
|
+
if (previous.height !== current.height || previous.width !== current.width) return false;
|
|
419
|
+
const rowBytes = width * bytesPerPixel;
|
|
420
|
+
const top = Math.floor(current.height * 0.18);
|
|
421
|
+
const bottom = Math.floor(current.height * 0.82);
|
|
422
|
+
const start = top * rowBytes;
|
|
423
|
+
const end = Math.max(start, bottom * rowBytes);
|
|
424
|
+
const previousSlice = previous.rgba.subarray(start, end);
|
|
425
|
+
const currentSlice = current.rgba.subarray(start, end);
|
|
426
|
+
return meanAbsoluteRowDifference(previousSlice, currentSlice) < 1.5;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function cropFrame(frame, cropTop, cropBottom, bytesPerPixel) {
|
|
430
|
+
const widthBytes = frame.width * bytesPerPixel;
|
|
431
|
+
const height = Math.max(0, frame.height - cropTop - cropBottom);
|
|
432
|
+
const start = cropTop * widthBytes;
|
|
433
|
+
const end = start + height * widthBytes;
|
|
434
|
+
return {
|
|
435
|
+
width: frame.width,
|
|
436
|
+
height,
|
|
437
|
+
rgba: Buffer.from(frame.rgba.subarray(start, end))
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function cropSliceHeight(slice, height, width, bytesPerPixel) {
|
|
442
|
+
const cleanHeight = Math.max(0, Math.min(slice.height, Number(height || 0)));
|
|
443
|
+
return {
|
|
444
|
+
width: slice.width,
|
|
445
|
+
height: cleanHeight,
|
|
446
|
+
rgba: Buffer.from(slice.rgba.subarray(0, cleanHeight * width * bytesPerPixel))
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function removeDuplicateOverlap(previous, current, width, bytesPerPixel) {
|
|
451
|
+
if (!previous?.rgba?.length || !current?.rgba?.length) return current;
|
|
452
|
+
const rowBytes = width * bytesPerPixel;
|
|
453
|
+
const maxOverlap = Math.min(previous.height, current.height, Math.floor(current.height * 0.82));
|
|
454
|
+
let overlapRows = 0;
|
|
455
|
+
for (let rows = maxOverlap; rows >= 48; rows -= 8) {
|
|
456
|
+
if (sampledOverlapDifference(previous, current, rows, width, bytesPerPixel) < 5.0) {
|
|
457
|
+
overlapRows = rows;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (overlapRows <= 0) return current;
|
|
462
|
+
return {
|
|
463
|
+
width: current.width,
|
|
464
|
+
height: current.height - overlapRows,
|
|
465
|
+
rgba: Buffer.from(current.rgba.subarray(overlapRows * rowBytes))
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function sampledOverlapDifference(previous, current, rows, width, bytesPerPixel) {
|
|
470
|
+
const rowBytes = width * bytesPerPixel;
|
|
471
|
+
const sampleRows = Math.min(28, rows);
|
|
472
|
+
const rowStep = Math.max(1, Math.floor(rows / sampleRows));
|
|
473
|
+
const xStart = Math.floor(width * 0.06);
|
|
474
|
+
const xEnd = Math.max(xStart + 1, Math.floor(width * 0.94));
|
|
475
|
+
const xStep = Math.max(1, Math.floor((xEnd - xStart) / 180));
|
|
476
|
+
let diff = 0;
|
|
477
|
+
let count = 0;
|
|
478
|
+
for (let y = 0; y < rows; y += rowStep) {
|
|
479
|
+
const previousRow = previous.height - rows + y;
|
|
480
|
+
const currentRow = y;
|
|
481
|
+
const previousBase = previousRow * rowBytes;
|
|
482
|
+
const currentBase = currentRow * rowBytes;
|
|
483
|
+
for (let x = xStart; x < xEnd; x += xStep) {
|
|
484
|
+
const offset = x * bytesPerPixel;
|
|
485
|
+
diff += Math.abs(previous.rgba[previousBase + offset] - current.rgba[currentBase + offset]);
|
|
486
|
+
diff += Math.abs(previous.rgba[previousBase + offset + 1] - current.rgba[currentBase + offset + 1]);
|
|
487
|
+
diff += Math.abs(previous.rgba[previousBase + offset + 2] - current.rgba[currentBase + offset + 2]);
|
|
488
|
+
count += 3;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return diff / Math.max(1, count);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function meanAbsoluteRowDifference(left, right) {
|
|
495
|
+
const len = Math.min(left.length, right.length);
|
|
496
|
+
if (len <= 0) return Number.POSITIVE_INFINITY;
|
|
497
|
+
let diff = 0;
|
|
498
|
+
const stride = Math.max(1, Math.floor(len / 20000));
|
|
499
|
+
let count = 0;
|
|
500
|
+
for (let i = 0; i < len; i += stride) {
|
|
501
|
+
diff += Math.abs(left[i] - right[i]);
|
|
502
|
+
count += 1;
|
|
503
|
+
}
|
|
504
|
+
return diff / Math.max(1, count);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function decodePng(buffer) {
|
|
508
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
509
|
+
if (!Buffer.from(buffer).subarray(0, 8).equals(signature)) throw new Error('invalid PNG signature');
|
|
510
|
+
let offset = 8;
|
|
511
|
+
let width = 0;
|
|
512
|
+
let height = 0;
|
|
513
|
+
let bitDepth = 0;
|
|
514
|
+
let colorType = 0;
|
|
515
|
+
const idat = [];
|
|
516
|
+
|
|
517
|
+
while (offset + 12 <= buffer.length) {
|
|
518
|
+
const length = buffer.readUInt32BE(offset);
|
|
519
|
+
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii');
|
|
520
|
+
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
|
521
|
+
offset += 12 + length;
|
|
522
|
+
if (type === 'IHDR') {
|
|
523
|
+
width = data.readUInt32BE(0);
|
|
524
|
+
height = data.readUInt32BE(4);
|
|
525
|
+
bitDepth = data[8];
|
|
526
|
+
colorType = data[9];
|
|
527
|
+
} else if (type === 'IDAT') {
|
|
528
|
+
idat.push(data);
|
|
529
|
+
} else if (type === 'IEND') {
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (!width || !height) throw new Error('invalid PNG IHDR');
|
|
534
|
+
const bytesPerPixel = pngBytesPerPixel(colorType, bitDepth);
|
|
535
|
+
const rowBytes = width * bytesPerPixel;
|
|
536
|
+
const inflated = zlib.inflateSync(Buffer.concat(idat));
|
|
537
|
+
const rgba = Buffer.alloc(rowBytes * height);
|
|
538
|
+
let src = 0;
|
|
539
|
+
let dst = 0;
|
|
540
|
+
let previous = Buffer.alloc(rowBytes);
|
|
541
|
+
for (let y = 0; y < height; y += 1) {
|
|
542
|
+
const filter = inflated[src++];
|
|
543
|
+
const row = Buffer.from(inflated.subarray(src, src + rowBytes));
|
|
544
|
+
src += rowBytes;
|
|
545
|
+
unfilterPngRow(row, previous, filter, bytesPerPixel);
|
|
546
|
+
row.copy(rgba, dst);
|
|
547
|
+
dst += rowBytes;
|
|
548
|
+
previous = row;
|
|
549
|
+
}
|
|
550
|
+
return {width, height, bitDepth, colorType, rgba};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function encodePng({width, height, rgba}) {
|
|
554
|
+
const rowBytes = width * 4;
|
|
555
|
+
const raw = Buffer.alloc((rowBytes + 1) * height);
|
|
556
|
+
for (let y = 0; y < height; y += 1) {
|
|
557
|
+
const rawOffset = y * (rowBytes + 1);
|
|
558
|
+
raw[rawOffset] = 0;
|
|
559
|
+
rgba.copy(raw, rawOffset + 1, y * rowBytes, (y + 1) * rowBytes);
|
|
560
|
+
}
|
|
561
|
+
const chunks = [
|
|
562
|
+
makePngChunk('IHDR', makeIhdr(width, height)),
|
|
563
|
+
makePngChunk('IDAT', zlib.deflateSync(raw, {level: 6})),
|
|
564
|
+
makePngChunk('IEND', Buffer.alloc(0))
|
|
565
|
+
];
|
|
566
|
+
return Buffer.concat([
|
|
567
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
568
|
+
...chunks
|
|
569
|
+
]);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function makeIhdr(width, height) {
|
|
573
|
+
const data = Buffer.alloc(13);
|
|
574
|
+
data.writeUInt32BE(width, 0);
|
|
575
|
+
data.writeUInt32BE(height, 4);
|
|
576
|
+
data[8] = 8;
|
|
577
|
+
data[9] = 6;
|
|
578
|
+
data[10] = 0;
|
|
579
|
+
data[11] = 0;
|
|
580
|
+
data[12] = 0;
|
|
581
|
+
return data;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function makePngChunk(type, data) {
|
|
585
|
+
const typeBuffer = Buffer.from(type, 'ascii');
|
|
586
|
+
const length = Buffer.alloc(4);
|
|
587
|
+
length.writeUInt32BE(data.length, 0);
|
|
588
|
+
const crcBuffer = Buffer.alloc(4);
|
|
589
|
+
crcBuffer.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
|
|
590
|
+
return Buffer.concat([length, typeBuffer, data, crcBuffer]);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function unfilterPngRow(row, previous, filter, bytesPerPixel) {
|
|
594
|
+
if (filter === 0) return;
|
|
595
|
+
for (let i = 0; i < row.length; i += 1) {
|
|
596
|
+
const left = i >= bytesPerPixel ? row[i - bytesPerPixel] : 0;
|
|
597
|
+
const up = previous[i] || 0;
|
|
598
|
+
const upLeft = i >= bytesPerPixel ? previous[i - bytesPerPixel] || 0 : 0;
|
|
599
|
+
let value = row[i];
|
|
600
|
+
if (filter === 1) value += left;
|
|
601
|
+
else if (filter === 2) value += up;
|
|
602
|
+
else if (filter === 3) value += Math.floor((left + up) / 2);
|
|
603
|
+
else if (filter === 4) value += paethPredictor(left, up, upLeft);
|
|
604
|
+
else throw new Error(`unsupported PNG filter ${filter}`);
|
|
605
|
+
row[i] = value & 0xff;
|
|
264
606
|
}
|
|
265
|
-
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function pngBytesPerPixel(colorType, bitDepth) {
|
|
610
|
+
if (bitDepth !== 8) throw new Error(`unsupported PNG bitDepth ${bitDepth}`);
|
|
611
|
+
if (colorType === 6) return 4;
|
|
612
|
+
if (colorType === 2) return 3;
|
|
613
|
+
if (colorType === 0) return 1;
|
|
614
|
+
throw new Error(`unsupported PNG colorType ${colorType}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function paethPredictor(a, b, c) {
|
|
618
|
+
const p = a + b - c;
|
|
619
|
+
const pa = Math.abs(p - a);
|
|
620
|
+
const pb = Math.abs(p - b);
|
|
621
|
+
const pc = Math.abs(p - c);
|
|
622
|
+
if (pa <= pb && pa <= pc) return a;
|
|
623
|
+
if (pb <= pc) return b;
|
|
624
|
+
return c;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const CRC_TABLE = makeCrcTable();
|
|
628
|
+
|
|
629
|
+
function crc32(buffer) {
|
|
630
|
+
let crc = 0xffffffff;
|
|
631
|
+
for (const byte of buffer) {
|
|
632
|
+
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
633
|
+
}
|
|
634
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function makeCrcTable() {
|
|
638
|
+
const table = new Uint32Array(256);
|
|
639
|
+
for (let n = 0; n < 256; n += 1) {
|
|
640
|
+
let c = n;
|
|
641
|
+
for (let k = 0; k < 8; k += 1) {
|
|
642
|
+
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
643
|
+
}
|
|
644
|
+
table[n] = c >>> 0;
|
|
645
|
+
}
|
|
646
|
+
return table;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function clampInt(value, min, max) {
|
|
650
|
+
const clean = Number.isFinite(value) ? Math.trunc(value) : min;
|
|
651
|
+
return Math.max(min, Math.min(max, clean));
|
|
266
652
|
}
|
|
267
653
|
|
|
268
654
|
function isAdbOfflineError(error) {
|
|
@@ -270,12 +656,20 @@ function isAdbOfflineError(error) {
|
|
|
270
656
|
return message.includes('device offline') || message.includes('device unauthorized');
|
|
271
657
|
}
|
|
272
658
|
|
|
273
|
-
function
|
|
659
|
+
function isAdbUnavailableError(error) {
|
|
274
660
|
const message = String(error?.message || error || '').toLowerCase();
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
661
|
+
return message.includes('device not found') || message.includes('no devices/emulators found');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function makeAdbUnavailableError(ctx, args, error) {
|
|
665
|
+
return new CrawlerError({
|
|
666
|
+
message: `adb_unavailable: ${error?.message || String(error)}`,
|
|
667
|
+
code: Code.AdbUnavailable,
|
|
668
|
+
context: {
|
|
669
|
+
serial: ctx.serial,
|
|
670
|
+
args
|
|
671
|
+
}
|
|
672
|
+
});
|
|
279
673
|
}
|
|
280
674
|
|
|
281
675
|
function isBounds(value) {
|
|
@@ -287,6 +681,18 @@ function isBounds(value) {
|
|
|
287
681
|
return [left, top, right, bottom].every(Number.isFinite) && right > left && bottom > top;
|
|
288
682
|
}
|
|
289
683
|
|
|
684
|
+
async function swipeScreenRatio(ctx, fromX, fromY, toX, toY, durationMs = 500) {
|
|
685
|
+
const size = await screenSize(ctx).catch(() => ({width: 720, height: 1280}));
|
|
686
|
+
await adbInput(ctx, [
|
|
687
|
+
'swipe',
|
|
688
|
+
String(Math.floor(size.width * fromX)),
|
|
689
|
+
String(Math.floor(size.height * fromY)),
|
|
690
|
+
String(Math.floor(size.width * toX)),
|
|
691
|
+
String(Math.floor(size.height * toY)),
|
|
692
|
+
String(durationMs)
|
|
693
|
+
]);
|
|
694
|
+
}
|
|
695
|
+
|
|
290
696
|
function safeFilePart(value) {
|
|
291
697
|
return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '-');
|
|
292
698
|
}
|
|
@@ -313,29 +719,40 @@ function adbInputTextForShell(raw) {
|
|
|
313
719
|
return `''${escapedText}''`;
|
|
314
720
|
}
|
|
315
721
|
|
|
722
|
+
function compactArgs(args) {
|
|
723
|
+
return (args || []).map((arg) => summarizeText(arg, 80)).join(' ');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function summarizeText(value, maxLen) {
|
|
727
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
728
|
+
if (text.length <= maxLen) return text;
|
|
729
|
+
return `${text.slice(0, maxLen)}...`;
|
|
730
|
+
}
|
|
731
|
+
|
|
316
732
|
export const Device = {
|
|
317
733
|
adbShell,
|
|
318
734
|
adbExec,
|
|
319
735
|
forceStopApp,
|
|
320
736
|
startActivity,
|
|
321
737
|
startLauncher,
|
|
322
|
-
launchPackageByMonkey,
|
|
323
738
|
waitForForeground,
|
|
324
739
|
screenSize,
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
740
|
+
screenDensity,
|
|
741
|
+
overrideScreenSize,
|
|
742
|
+
resetScreenSize,
|
|
743
|
+
resetScreenDensity,
|
|
328
744
|
tapAbsolute,
|
|
329
745
|
click,
|
|
330
|
-
swipeRatio,
|
|
331
746
|
move,
|
|
332
747
|
pressBack,
|
|
333
|
-
|
|
748
|
+
dismissPermissionDialog,
|
|
334
749
|
wakeAndUnlock,
|
|
335
750
|
focusedActivity,
|
|
336
751
|
activityIncludes,
|
|
337
752
|
waitForActivity,
|
|
753
|
+
screenshotPng,
|
|
338
754
|
screenshotBase64,
|
|
755
|
+
longScreenshotBase64,
|
|
339
756
|
saveDebugScreenshot,
|
|
340
757
|
clearInputByDelete,
|
|
341
758
|
typeText,
|