@skrillex1224/android-toolkit 0.1.7 → 0.1.9
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 +34 -4
- package/entrys/node.js +2 -0
- package/index.d.ts +191 -0
- package/package.json +7 -2
- package/src/apify-kit.js +15 -0
- package/src/constants.js +50 -0
- package/src/context.js +2 -2
- package/src/device.js +385 -10
- package/src/frida-client.js +7 -1
- package/src/internals/compression.js +188 -0
- package/src/launch.js +2 -0
- package/src/share.js +644 -0
package/src/device.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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
|
|
|
@@ -95,6 +96,26 @@ export async function screenSize(ctx) {
|
|
|
95
96
|
};
|
|
96
97
|
}
|
|
97
98
|
|
|
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;
|
|
103
|
+
}
|
|
104
|
+
|
|
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});
|
|
109
|
+
}
|
|
110
|
+
|
|
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});
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
export async function tapAbsolute(ctx, x, y) {
|
|
99
120
|
// 点击绝对像素坐标,适合已经从截图或 DOM rect 算出的精确点。
|
|
100
121
|
await adbInput(ctx, ['tap', String(Math.round(Number(x))), String(Math.round(Number(y)))]);
|
|
@@ -218,25 +239,71 @@ export async function waitForActivity(ctx, predicate, timeoutMs = 15000, interva
|
|
|
218
239
|
|
|
219
240
|
export async function screenshotBase64(ctx) {
|
|
220
241
|
// 获取当前屏幕 PNG,并转成 base64 写入 dataset artifact。
|
|
221
|
-
const stdout = await
|
|
222
|
-
timeoutMs: 15000,
|
|
223
|
-
encoding: 'buffer',
|
|
224
|
-
maxBuffer: 8 * 1024 * 1024
|
|
225
|
-
});
|
|
242
|
+
const stdout = await screenshotPng(ctx);
|
|
226
243
|
return Buffer.from(stdout).toString('base64');
|
|
227
244
|
}
|
|
228
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
|
+
|
|
229
300
|
export async function saveDebugScreenshot(ctx, name) {
|
|
230
301
|
// 保存调试截图到 /tmp,主要用于本地定位点击点和页面状态。
|
|
231
302
|
if (!ctx.serial) return '';
|
|
232
303
|
const safeRunId = safeFilePart(ctx.runId || 'manual');
|
|
233
304
|
const safeName = safeFilePart(name || 'screenshot');
|
|
234
305
|
const out = `/tmp/android-toolkit-${safeRunId}-${safeName}.png`;
|
|
235
|
-
const stdout = await
|
|
236
|
-
timeoutMs: 15000,
|
|
237
|
-
encoding: 'buffer',
|
|
238
|
-
maxBuffer: 8 * 1024 * 1024
|
|
239
|
-
});
|
|
306
|
+
const stdout = await screenshotPng(ctx);
|
|
240
307
|
fs.writeFileSync(out, stdout);
|
|
241
308
|
Logger.info(`debug screenshot saved ${out}`);
|
|
242
309
|
return out;
|
|
@@ -282,6 +349,308 @@ async function adbInput(ctx, args, options = {}) {
|
|
|
282
349
|
await adbShell(ctx, ['input', ...args], options);
|
|
283
350
|
}
|
|
284
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);
|
|
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;
|
|
606
|
+
}
|
|
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));
|
|
652
|
+
}
|
|
653
|
+
|
|
285
654
|
function isAdbOfflineError(error) {
|
|
286
655
|
const message = String(error?.message || error || '').toLowerCase();
|
|
287
656
|
return message.includes('device offline') || message.includes('device unauthorized');
|
|
@@ -368,6 +737,10 @@ export const Device = {
|
|
|
368
737
|
startLauncher,
|
|
369
738
|
waitForForeground,
|
|
370
739
|
screenSize,
|
|
740
|
+
screenDensity,
|
|
741
|
+
overrideScreenSize,
|
|
742
|
+
resetScreenSize,
|
|
743
|
+
resetScreenDensity,
|
|
371
744
|
tapAbsolute,
|
|
372
745
|
click,
|
|
373
746
|
move,
|
|
@@ -377,7 +750,9 @@ export const Device = {
|
|
|
377
750
|
focusedActivity,
|
|
378
751
|
activityIncludes,
|
|
379
752
|
waitForActivity,
|
|
753
|
+
screenshotPng,
|
|
380
754
|
screenshotBase64,
|
|
755
|
+
longScreenshotBase64,
|
|
381
756
|
saveDebugScreenshot,
|
|
382
757
|
clearInputByDelete,
|
|
383
758
|
typeText,
|
package/src/frida-client.js
CHANGED
|
@@ -93,7 +93,12 @@ export async function runScript(ctx, source, options = {}) {
|
|
|
93
93
|
maxLines: Number(options.maxLines || 1200),
|
|
94
94
|
outputPath: options.outputPath || ''
|
|
95
95
|
});
|
|
96
|
-
|
|
96
|
+
let waitError = null;
|
|
97
|
+
try {
|
|
98
|
+
await recorder.waitForEvent((event) => event?.type !== 'parse_error', Number(options.timeoutMs || 10000));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
waitError = error;
|
|
101
|
+
}
|
|
97
102
|
const result = await recorder.stop();
|
|
98
103
|
const scriptEvents = result.events.filter((event) => event?.type !== 'parse_error');
|
|
99
104
|
if (scriptEvents.length === 0) {
|
|
@@ -102,6 +107,7 @@ export async function runScript(ctx, source, options = {}) {
|
|
|
102
107
|
duration: Logger.duration(startedAt),
|
|
103
108
|
lineCount: result.lines.length
|
|
104
109
|
});
|
|
110
|
+
if (waitError) throw waitError;
|
|
105
111
|
throw new CrawlerError({
|
|
106
112
|
message: `frida_script_failed: ${label} did not emit ${marker.trim()}`,
|
|
107
113
|
code: Code.FridaUnavailable,
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {Jimp, JimpMime, ResizeStrategy} from 'jimp';
|
|
2
|
+
|
|
3
|
+
import {Logger} from '../logger.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
|
|
6
|
+
const DEFAULT_SCREENSHOT_OUTPUT_TYPE = 'jpeg';
|
|
7
|
+
const DEFAULT_SCREENSHOT_QUALITY = 0.72;
|
|
8
|
+
const DEFAULT_SCREENSHOT_MIN_QUALITY = 0.38;
|
|
9
|
+
const DEFAULT_SCREENSHOT_MIN_SCALE = 0.25;
|
|
10
|
+
const SUPPORTED_SCREENSHOT_OUTPUT_TYPES = new Set(['jpeg']);
|
|
11
|
+
|
|
12
|
+
const toPositiveInteger = (value, fallback = 0) => {
|
|
13
|
+
const number = Math.floor(Number(value) || 0);
|
|
14
|
+
return number > 0 ? number : fallback;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const normalizeQuality = (value, fallback) => {
|
|
18
|
+
const number = Number(value);
|
|
19
|
+
if (!Number.isFinite(number) || number <= 0) return fallback;
|
|
20
|
+
const normalized = number > 1 ? number / 100 : number;
|
|
21
|
+
return Math.min(1, Math.max(0.01, normalized));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const normalizeScale = (value, fallback) => {
|
|
25
|
+
const number = Number(value);
|
|
26
|
+
if (!Number.isFinite(number) || number <= 0) return fallback;
|
|
27
|
+
return Math.min(1, Math.max(0.05, number));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const normalizeScreenshotOutputType = (value) => {
|
|
31
|
+
const raw = String(value || DEFAULT_SCREENSHOT_OUTPUT_TYPE).trim().toLowerCase();
|
|
32
|
+
const normalized = raw === 'jpg' ? 'jpeg' : raw;
|
|
33
|
+
return SUPPORTED_SCREENSHOT_OUTPUT_TYPES.has(normalized)
|
|
34
|
+
? normalized
|
|
35
|
+
: DEFAULT_SCREENSHOT_OUTPUT_TYPE;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const getBase64BytesFromBuffer = (buffer) => Math.ceil(buffer.length / 3) * 4;
|
|
39
|
+
|
|
40
|
+
const toJpegQuality = (value) => Math.round(normalizeQuality(value, DEFAULT_SCREENSHOT_QUALITY) * 100);
|
|
41
|
+
|
|
42
|
+
export const resolveImageCompression = (options = {}) => {
|
|
43
|
+
const explicit = options.compression;
|
|
44
|
+
const source = explicit && typeof explicit === 'object' && !Array.isArray(explicit)
|
|
45
|
+
? explicit
|
|
46
|
+
: {};
|
|
47
|
+
|
|
48
|
+
const enabled = explicit !== false
|
|
49
|
+
&& source.enabled !== false
|
|
50
|
+
&& options.compress !== false;
|
|
51
|
+
|
|
52
|
+
const quality = normalizeQuality(
|
|
53
|
+
source.quality ?? options.quality,
|
|
54
|
+
DEFAULT_SCREENSHOT_QUALITY
|
|
55
|
+
);
|
|
56
|
+
const minQuality = Math.min(
|
|
57
|
+
quality,
|
|
58
|
+
normalizeQuality(source.minQuality ?? options.minQuality, DEFAULT_SCREENSHOT_MIN_QUALITY)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
enabled,
|
|
63
|
+
maxBytes: toPositiveInteger(
|
|
64
|
+
source.maxBytes
|
|
65
|
+
?? source.maxBase64Bytes
|
|
66
|
+
?? options.maxBytes
|
|
67
|
+
?? options.maxBase64Bytes,
|
|
68
|
+
DEFAULT_SCREENSHOT_MAX_BYTES
|
|
69
|
+
),
|
|
70
|
+
outputType: normalizeScreenshotOutputType(
|
|
71
|
+
source.type
|
|
72
|
+
?? source.outputType
|
|
73
|
+
?? options.type
|
|
74
|
+
?? options.outputType
|
|
75
|
+
),
|
|
76
|
+
quality,
|
|
77
|
+
minQuality,
|
|
78
|
+
minScale: normalizeScale(
|
|
79
|
+
source.minScale ?? options.minScale,
|
|
80
|
+
DEFAULT_SCREENSHOT_MIN_SCALE
|
|
81
|
+
),
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const encodeJpeg = async (sourceImage, compression, scale, quality) => {
|
|
86
|
+
const width = Math.max(1, Math.round(sourceImage.bitmap.width * scale));
|
|
87
|
+
const height = Math.max(1, Math.round(sourceImage.bitmap.height * scale));
|
|
88
|
+
const image = sourceImage.clone();
|
|
89
|
+
|
|
90
|
+
if (scale < 0.999) {
|
|
91
|
+
image.resize({
|
|
92
|
+
w: width,
|
|
93
|
+
h: height,
|
|
94
|
+
mode: ResizeStrategy.BILINEAR,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const buffer = await image.getBuffer(JimpMime.jpeg, {quality});
|
|
99
|
+
return {
|
|
100
|
+
buffer,
|
|
101
|
+
bytes: getBase64BytesFromBuffer(buffer),
|
|
102
|
+
width,
|
|
103
|
+
height,
|
|
104
|
+
quality,
|
|
105
|
+
scale: Number(scale.toFixed(3)),
|
|
106
|
+
format: compression.outputType,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const compressImageBuffer = async (buffer, compression) => {
|
|
111
|
+
const sourceImage = await Jimp.read(buffer);
|
|
112
|
+
const maxQuality = toJpegQuality(compression.quality);
|
|
113
|
+
const minQuality = Math.min(maxQuality, toJpegQuality(compression.minQuality));
|
|
114
|
+
let quality = maxQuality;
|
|
115
|
+
let scale = 1;
|
|
116
|
+
let smallest = null;
|
|
117
|
+
|
|
118
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
119
|
+
const candidate = await encodeJpeg(sourceImage, compression, scale, quality);
|
|
120
|
+
if (!smallest || candidate.bytes < smallest.bytes) {
|
|
121
|
+
smallest = candidate;
|
|
122
|
+
}
|
|
123
|
+
if (candidate.bytes <= compression.maxBytes) {
|
|
124
|
+
return {...candidate, withinLimit: true};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (quality > minQuality) {
|
|
128
|
+
quality = Math.max(minQuality, Math.floor(quality * 0.75));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const ratio = Math.sqrt(compression.maxBytes / Math.max(1, candidate.bytes));
|
|
133
|
+
const nextScale = Math.max(
|
|
134
|
+
compression.minScale,
|
|
135
|
+
Math.min(scale * 0.85, scale * ratio * 0.94)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (nextScale >= scale * 0.99 || scale <= compression.minScale) {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
scale = nextScale;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const finalCandidate = await encodeJpeg(sourceImage, compression, compression.minScale, minQuality);
|
|
146
|
+
const fallback = !smallest || finalCandidate.bytes < smallest.bytes
|
|
147
|
+
? finalCandidate
|
|
148
|
+
: smallest;
|
|
149
|
+
return {...fallback, withinLimit: fallback.bytes <= compression.maxBytes};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const compressImageBufferToBase64 = async (buffer, compression) => {
|
|
153
|
+
const originalBytes = getBase64BytesFromBuffer(buffer);
|
|
154
|
+
if (!compression.enabled || originalBytes <= compression.maxBytes) {
|
|
155
|
+
return buffer.toString('base64');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = await compressImageBuffer(buffer, compression).catch((error) => {
|
|
159
|
+
Logger.warn('captureScreen 压缩失败,返回原图', {message: error?.message || String(error)});
|
|
160
|
+
return null;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (!result?.buffer) {
|
|
164
|
+
return buffer.toString('base64');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (result.withinLimit) {
|
|
168
|
+
Logger.info('captureScreen 已压缩', {
|
|
169
|
+
originalBytes,
|
|
170
|
+
outputBytes: result.bytes,
|
|
171
|
+
format: result.format,
|
|
172
|
+
quality: result.quality,
|
|
173
|
+
scale: result.scale,
|
|
174
|
+
size: `${result.width}x${result.height}`,
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
Logger.warn('captureScreen 压缩后仍超过目标', {
|
|
178
|
+
originalBytes,
|
|
179
|
+
outputBytes: result.bytes,
|
|
180
|
+
maxBytes: compression.maxBytes,
|
|
181
|
+
format: result.format,
|
|
182
|
+
quality: result.quality,
|
|
183
|
+
scale: result.scale,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return result.buffer.toString('base64');
|
|
188
|
+
};
|
package/src/launch.js
CHANGED
|
@@ -5,6 +5,7 @@ import {createAndroidContext} from './context.js';
|
|
|
5
5
|
import {Device} from './device.js';
|
|
6
6
|
import {Frida} from './frida-client.js';
|
|
7
7
|
import {Logger} from './logger.js';
|
|
8
|
+
import {Share} from './share.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_INPUT_PATH = '/apify_storage/input.json';
|
|
10
11
|
const DEFAULT_OUTPUT_PATH = '/apify_storage/output.json';
|
|
@@ -38,6 +39,7 @@ export const Launch = {
|
|
|
38
39
|
ApifyKit: apifyKit,
|
|
39
40
|
Device,
|
|
40
41
|
Frida,
|
|
42
|
+
Share,
|
|
41
43
|
Logger
|
|
42
44
|
};
|
|
43
45
|
|