@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.
@@ -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
+ };
@@ -14,6 +14,9 @@ function getConfig() {
14
14
  keywords: Array.isArray(CONFIG.keywords)
15
15
  ? CONFIG.keywords.map((item) => String(item || '')).filter(Boolean)
16
16
  : [],
17
+ // 调试开关:捕获所有能解析到的 WebView JS 回调,用于先确认业务事件名。
18
+ // 默认关闭,避免正式任务记录无关事件。
19
+ captureAllEvents: CONFIG.captureAllEvents === true,
17
20
  includeRawJs: CONFIG.includeRawJs === true,
18
21
  maxRawJsLength: Number(CONFIG.maxRawJsLength || 1200),
19
22
  maxPayloadLength: Number(CONFIG.maxPayloadLength || 4 * 1024 * 1024)
@@ -86,6 +89,7 @@ function extractCallPayload(rawSource) {
86
89
 
87
90
  function shouldCapture(config, rawJs, parsed) {
88
91
  const eventName = String(parsed?.eventName || '').trim();
92
+ if (config.captureAllEvents && (eventName || parsed?.payload || config.includeRawJs)) return true;
89
93
  if (eventName && config.eventNames.has(eventName)) return true;
90
94
  if (config.keywords.length === 0) return false;
91
95
  const raw = String(rawJs || '');
package/src/launch.js CHANGED
@@ -5,48 +5,66 @@ 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';
9
+
10
+ const DEFAULT_INPUT_PATH = '/apify_storage/input.json';
11
+ const DEFAULT_OUTPUT_PATH = '/apify_storage/output.json';
8
12
 
9
13
  export const Launch = {
10
14
  /**
11
15
  * Android androider 的统一入口。
12
16
  *
13
17
  * 这层等价于普通 visitor 里的 Actor.init + Actor.getInput + requestHandler 的组合。
14
- * 注意:toolkit 不读取环境变量,input/output 路径必须由具体 androider 的
15
- * main.js 显式传入。这样 toolkit 不会和某个 runner、某个云手机平台绑定。
18
+ * 容器里默认使用 cluster 固定的 input/output 路径;本地调试仍可由
19
+ * main.js 透传 argv 覆盖。
16
20
  *
17
21
  * handler 内建议只写业务流程;输入、ctx、toolkit 模块都从参数里取。
18
22
  */
19
23
  async run(handler, options = {}) {
20
- const input = options.input || JSON.parse(fs.readFileSync(requiredOption(options.inputPath, 'inputPath'), 'utf8'));
24
+ const startedAt = Date.now();
25
+ const inputPath = pathOption(options.inputPath, DEFAULT_INPUT_PATH);
26
+ const outputPath = pathOption(options.outputPath, DEFAULT_OUTPUT_PATH);
27
+ const input = options.input || JSON.parse(fs.readFileSync(inputPath, 'utf8'));
21
28
  const ctx = options.ctx || createAndroidContext(input, options.contextDefaults || {});
29
+ ctx.runId = ctx.runId || input.run_id || input.runId || input.runtime?.run_id || input.runtime?.runId || '';
22
30
  const apifyKit = await ApifyKit.useApifyKit({
23
31
  reset: true,
24
32
  input,
25
33
  ctx,
26
- inputPath: options.inputPath,
27
- outputPath: requiredOption(options.outputPath, 'outputPath')
34
+ inputPath,
35
+ outputPath
28
36
  });
29
37
 
30
38
  const kit = {
31
39
  ApifyKit: apifyKit,
32
40
  Device,
33
41
  Frida,
42
+ Share,
34
43
  Logger
35
44
  };
36
45
 
37
46
  try {
47
+ Logger.start('Launch run', {
48
+ inputPath,
49
+ outputPath,
50
+ runId: ctx.runId,
51
+ serial: ctx.serial,
52
+ packageName: ctx.packageName,
53
+ queryChars: ctx.query.length
54
+ });
38
55
  await handler({input, ctx, kit, ApifyKit: apifyKit});
56
+ Logger.success('Launch run', {duration: Logger.duration(startedAt)});
39
57
  } catch (error) {
40
- Logger.fail('Launch handler failed', error);
58
+ Logger.fail(`Launch handler failed duration=${Logger.duration(startedAt)}`, error);
41
59
  if (!apifyKit.hasPushed()) {
42
60
  await apifyKit.pushFailed(error);
43
61
  }
62
+ throw error;
44
63
  }
45
64
  }
46
65
  };
47
66
 
48
- function requiredOption(value, name) {
67
+ function pathOption(value, fallback) {
49
68
  const clean = String(value || '').trim();
50
- if (!clean) throw new Error(`Launch.run requires ${name}`);
51
- return clean;
69
+ return clean || fallback;
52
70
  }
package/src/logger.js CHANGED
@@ -9,8 +9,11 @@ const defaultLogger = {
9
9
  let activeLogger = defaultLogger;
10
10
 
11
11
  function write(level, message, detail = '') {
12
- const line = detail ? `[android-toolkit] ${message} ${detail}` : `[android-toolkit] ${message}`;
13
- const writer = activeLogger[level] || activeLogger.info || console.error;
12
+ const formattedDetail = formatDetail(detail);
13
+ const line = formattedDetail
14
+ ? `[${formatTimestamp()}] [android-toolkit] ${message} ${formattedDetail}`
15
+ : `[${formatTimestamp()}] [android-toolkit] ${message}`;
16
+ const writer = activeLogger[level] || activeLogger.warning || activeLogger.warn || activeLogger.info || console.error;
14
17
  writer.call(activeLogger, line);
15
18
  }
16
19
 
@@ -36,6 +39,23 @@ export const Logger = {
36
39
  info(message, detail = '') {
37
40
  write('info', message, detail);
38
41
  },
42
+ debug(message, detail = '') {
43
+ write('debug', message, detail);
44
+ },
45
+ duration(startedAt) {
46
+ return `${((Date.now() - startedAt) / 1000).toFixed(2)}s`;
47
+ },
48
+ createLogger(scope) {
49
+ const prefix = scope ? `[${scope}] ` : '';
50
+ return {
51
+ start: (message, detail = '') => Logger.start(`${prefix}${message}`, detail),
52
+ success: (message, detail = '') => Logger.success(`${prefix}${message}`, detail),
53
+ fail: (message, error = '') => Logger.fail(`${prefix}${message}`, error),
54
+ warn: (message, detail = '') => Logger.warn(`${prefix}${message}`, detail),
55
+ info: (message, detail = '') => Logger.info(`${prefix}${message}`, detail),
56
+ debug: (message, detail = '') => Logger.debug(`${prefix}${message}`, detail)
57
+ };
58
+ },
39
59
  useTemplate() {
40
60
  return {
41
61
  taskStart: (detail) => Logger.start('任务开始', detail),
@@ -51,3 +71,41 @@ export const Logger = {
51
71
  export function sleep(ms) {
52
72
  return new Promise((resolve) => setTimeout(resolve, ms));
53
73
  }
74
+
75
+ function formatTimestamp(date = new Date()) {
76
+ const pad = (value, size = 2) => String(value).padStart(size, '0');
77
+ return [
78
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
79
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
80
+ ].join(' ');
81
+ }
82
+
83
+ function formatDetail(detail) {
84
+ if (detail == null || detail === '') return '';
85
+ if (typeof detail === 'string') return detail;
86
+ if (detail instanceof Error) return detail.message;
87
+ if (Array.isArray(detail)) {
88
+ return detail.map(formatDetail).filter(Boolean).join(' ');
89
+ }
90
+ if (typeof detail === 'object') {
91
+ const parts = [];
92
+ for (const [key, value] of Object.entries(detail)) {
93
+ if (value == null || value === '') continue;
94
+ if (typeof value === 'object') {
95
+ parts.push(`${key}=${safeJson(value)}`);
96
+ } else {
97
+ parts.push(`${key}=${String(value)}`);
98
+ }
99
+ }
100
+ return parts.join(' ');
101
+ }
102
+ return String(detail);
103
+ }
104
+
105
+ function safeJson(value) {
106
+ try {
107
+ return JSON.stringify(value);
108
+ } catch {
109
+ return String(value);
110
+ }
111
+ }