@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/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) throw error;
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
- const {stdout} = await execFileAsync(ctx.adbPath, args, {
31
- timeout: Number(options.timeoutMs || 15000),
32
- maxBuffer: Number(options.maxBuffer || 12 * 1024 * 1024),
33
- encoding: options.encoding || 'utf8'
34
- });
35
- return stdout;
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 px(ctx, ratio) {
85
- // 把横向比例坐标转成屏幕像素。
86
- return String(Math.floor((ctx.screen?.width || 720) * ratio));
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 py(ctx, ratio) {
90
- // 把纵向比例坐标转成屏幕像素。
91
- return String(Math.floor((ctx.screen?.height || 1280) * ratio));
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 tapRatio(ctx, x, y) {
95
- // 点击比例坐标,适合不同分辨率设备共用同一套大致点位。
96
- await adbInput(ctx, ['tap', px(ctx, x), py(ctx, y)]);
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 pressEnter(ctx) {
143
- // 发送 Android 回车键,常用于提交搜索框。
144
- await adbInput(ctx, ['keyevent', 'ENTER']).catch(() => { });
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 swipeRatio(ctx, 0.50, 0.82, 0.50, 0.25, 350).catch(() => { });
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 focused = '';
189
+ let currentFocus = '';
190
+ let appWindowFocus = '';
163
191
  for (const line of String(out || '').split('\n')) {
164
- if (line.includes('mCurrentFocus') || line.includes('mFocusedApp')) {
165
- const trimmed = line.trim();
166
- if (!fallback) fallback = trimmed;
167
- if (!trimmed.includes('=null')) focused = trimmed;
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 focused || fallback || String(out || '').trim();
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)) return 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 adbExec(ctx, ['-s', ctx.serial, 'exec-out', 'screencap', '-p'], {
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 adbExec(ctx, ['-s', ctx.serial, 'exec-out', 'screencap', '-p'], {
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
- // input tap/swipe/keyevent 在真机和云手机上偶发返回非 0,但重放同一条命令通常会成功。
253
- // 这层只兜底设备输入抖动;页面级失败仍然交给业务 runStep/任务重试判断。
254
- const retryTimes = Math.max(0, Number(options.retryTimes ?? 2));
255
- let lastError = null;
256
- for (let attempt = 0; attempt <= retryTimes; attempt += 1) {
257
- try {
258
- return await adbShell(ctx, ['input', ...args], options);
259
- } catch (error) {
260
- lastError = error;
261
- if (attempt >= retryTimes || !isRetryableAdbInputError(error)) throw error;
262
- await sleep(250 * (attempt + 1));
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
- throw lastError;
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 isRetryableAdbInputError(error) {
659
+ function isAdbUnavailableError(error) {
274
660
  const message = String(error?.message || error || '').toLowerCase();
275
- if (isAdbOfflineError(error)) return true;
276
- if (message.includes('command failed:') && message.includes(' shell input ')) return true;
277
- if (message.includes('closed') || message.includes('broken pipe') || message.includes('connection reset')) return true;
278
- return false;
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
- px,
326
- py,
327
- tapRatio,
740
+ screenDensity,
741
+ overrideScreenSize,
742
+ resetScreenSize,
743
+ resetScreenDensity,
328
744
  tapAbsolute,
329
745
  click,
330
- swipeRatio,
331
746
  move,
332
747
  pressBack,
333
- pressEnter,
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,