@skrillex1224/android-toolkit 0.1.1 → 0.1.7

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 CHANGED
@@ -13,7 +13,7 @@ const { Launch } = useAndroidToolKit();
13
13
  await Launch.run(async ({ input, ctx, kit }) => {
14
14
  const result = await kit.ApifyKit.runStep('执行业务流程', null, async () => {
15
15
  await kit.Device.forceStopApp(ctx, ctx.packageName);
16
- await kit.Device.tapRatio(ctx, 0.5, 0.5);
16
+ await kit.Device.click(ctx, { x: 360, y: 640 });
17
17
  return { answer: `echo: ${input.query}`, sources: [] };
18
18
  });
19
19
 
@@ -34,7 +34,7 @@ await Launch.run(async ({ input, ctx, kit }) => {
34
34
  | `Launch` | 接受显式 `inputPath` / `outputPath`,创建 `ctx`,调用业务 handler |
35
35
  | `ApifyKit` | 提供 `runStep`、`runStepLoose`、`pushSuccess`、`pushFailed`,并统一补齐 `code/status/timestamp/data` |
36
36
  | `Device` | ADB 操作层:启动、点击、滑动、输入中文、截图、Activity 检查 |
37
- | `Frida` | 启动 WebView event recorder,记录 `evaluateJavascript` / `loadUrl` 注入事件 |
37
+ | `Frida` | Frida attach、短脚本执行、SQLite 查询、WebView event recorder |
38
38
  | `Context` | 从显式 input/defaults 生成 Android 运行上下文 |
39
39
  | `Errors` | `CrawlerError` 和错误序列化;失败码由业务显式抛出的 `CrawlerError` 决定 |
40
40
 
@@ -44,6 +44,7 @@ await Launch.run(async ({ input, ctx, kit }) => {
44
44
 
45
45
  - ADB 执行、云手机 TCP ADB 离线重连、点击、滑动、截图、中文输入。
46
46
  - Frida attach 目标 App pid,并启动 WebView JS 注入事件 recorder。
47
+ - App 进程内 SQLite 查询:业务方提供 db 路径规则、SQL 和参数,toolkit 只返回 cursor rows。
47
48
  - `runStep` / `runStepLoose` / `pushSuccess` / `pushFailed`。
48
49
  - 通用错误码和失败 dataset 兜底结构。
49
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skrillex1224/android-toolkit",
3
- "version": "0.1.1",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/apify-kit.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import {Code, Status} from './constants.js';
4
4
  import {createAndroidContext} from './context.js';
5
5
  import {CrawlerError, serializeError} from './errors.js';
6
- import {Logger, sleep} from './logger.js';
6
+ import {Logger} from './logger.js';
7
7
 
8
8
  let instance = null;
9
9
 
@@ -30,46 +30,25 @@ async function createApifyKit(options = {}) {
30
30
  * - step 是中文步骤名;
31
31
  * - target 位置保留给 device/session,便于未来接入云真机 live view;
32
32
  * - actionFn 是真正业务动作;
33
- * - retry 支持 direct / before hook;
34
33
  * - failActor=true 时会先 pushFailed,再把错误继续抛给 Launch。
35
34
  */
36
35
  async runStep(step, target, actionFn, options = {}) {
37
36
  if (target) lastDevice = target;
38
- const {failActor = true, retry = {}} = options;
39
- const retryTimes = Number(retry.times || 0);
40
- let lastError = null;
41
-
42
- for (let attempt = 0; attempt <= retryTimes; attempt += 1) {
43
- const suffix = attempt > 0 ? ` (重试 #${attempt})` : '';
44
- Logger.start(`[Step] ${step}${suffix}`);
45
- try {
46
- const result = await actionFn();
47
- Logger.success(`[Step] ${step}${suffix}`);
48
- return result;
49
- } catch (error) {
50
- lastError = error;
51
- Logger.fail(`[Step] ${step}${suffix}`, error);
52
- if (attempt < retryTimes) {
53
- if (typeof retry.before === 'function') {
54
- Logger.start(`[RetryStep] 执行 before 钩子: ${step}`);
55
- await retry.before(target, attempt + 1, error);
56
- Logger.success(`[RetryStep] before 钩子完成: ${step}`);
57
- } else {
58
- const delayMs = Number(retry.delayMs || 3000);
59
- Logger.start(`[RetryStep] 等待 ${delayMs}ms: ${step}`);
60
- await sleep(delayMs);
61
- }
62
- }
37
+ const {failActor = true} = options;
38
+ const startedAt = Date.now();
39
+
40
+ Logger.start(`[Step] ${step}`, stepDetail({target, failActor}));
41
+ try {
42
+ const result = await actionFn();
43
+ Logger.success(`[Step] ${step}`, {duration: Logger.duration(startedAt)});
44
+ return result;
45
+ } catch (error) {
46
+ Logger.fail(`[Step] ${step} duration=${Logger.duration(startedAt)}`, error);
47
+ if (failActor) {
48
+ await this.pushFailed(error, {step}, {target});
63
49
  }
50
+ throw error;
64
51
  }
65
-
66
- if (failActor) {
67
- await this.pushFailed(lastError, {
68
- step,
69
- retryAttempts: retryTimes
70
- }, {target});
71
- }
72
- throw lastError;
73
52
  },
74
53
 
75
54
  /**
@@ -92,15 +71,19 @@ async function createApifyKit(options = {}) {
92
71
  * code/status/timestamp/data 外壳。这样 commander/transformer 看到的是同类结构。
93
72
  */
94
73
  async pushSuccess(data = {}, options = {}) {
74
+ const dataSummary = summarizeDatasetData(data);
95
75
  pushed = true;
96
- fs.writeFileSync(io.outputPath, JSON.stringify([{
76
+ appendOutput(io.outputPath, {
97
77
  code: Code.Success,
98
78
  status: Status.Success,
99
79
  timestamp: new Date().toISOString(),
100
80
  data: sanitizeData(data),
101
81
  ...(options.meta ? {meta: sanitizeData(options.meta)} : {})
102
- }], null, 2) + '\n', 'utf8');
103
- Logger.success('pushSuccess', 'Data pushed');
82
+ });
83
+ Logger.success('pushSuccess', {
84
+ outputPath: io.outputPath,
85
+ ...dataSummary
86
+ });
104
87
  },
105
88
 
106
89
  /**
@@ -115,15 +98,19 @@ async function createApifyKit(options = {}) {
115
98
  : CrawlerError.from(error, {code: Code.UnknownError, context: meta});
116
99
 
117
100
  pushed = true;
118
- fs.writeFileSync(io.outputPath, JSON.stringify([{
101
+ appendOutput(io.outputPath, {
119
102
  code: normalized.code,
120
103
  status: Status.Failed,
121
104
  error: serializeError(normalized),
122
105
  meta: sanitizeData(meta),
123
106
  context: sanitizeData(normalized.context),
124
107
  timestamp: new Date().toISOString()
125
- }], null, 2) + '\n', 'utf8');
126
- Logger.success('pushFailed', 'Error data pushed');
108
+ });
109
+ Logger.success('pushFailed', {
110
+ outputPath: io.outputPath,
111
+ code: normalized.code,
112
+ message: normalized.message
113
+ });
127
114
  },
128
115
 
129
116
  hasPushed() {
@@ -147,12 +134,50 @@ export const ApifyKit = {
147
134
  useApifyKit
148
135
  };
149
136
 
137
+ function appendOutput(outputPath, item) {
138
+ const items = readOutputArray(outputPath);
139
+ items.push(item);
140
+ fs.writeFileSync(outputPath, JSON.stringify(items, null, 2) + '\n', 'utf8');
141
+ }
142
+
143
+ function readOutputArray(outputPath) {
144
+ if (!fs.existsSync(outputPath)) return [];
145
+ const raw = fs.readFileSync(outputPath, 'utf8').trim();
146
+ if (!raw) return [];
147
+ const parsed = JSON.parse(raw);
148
+ if (!Array.isArray(parsed)) {
149
+ throw new Error(`Android output must be a JSON array: ${outputPath}`);
150
+ }
151
+ return parsed;
152
+ }
153
+
150
154
  function requiredOption(value, name) {
151
155
  const clean = String(value || '').trim();
152
156
  if (!clean) throw new Error(`ApifyKit.useApifyKit requires ${name}`);
153
157
  return clean;
154
158
  }
155
159
 
160
+ function stepDetail({target, failActor}) {
161
+ const detail = {failActor};
162
+ if (!target) return detail;
163
+ if (typeof target === 'string') detail.target = target;
164
+ else if (typeof target === 'object') {
165
+ detail.serial = target.serial;
166
+ detail.packageName = target.packageName;
167
+ detail.runId = target.runId;
168
+ }
169
+ return detail;
170
+ }
171
+
172
+ function summarizeDatasetData(data) {
173
+ const summary = {};
174
+ if (typeof data?.answer === 'string') summary.answerChars = data.answer.length;
175
+ if (Array.isArray(data?.sources)) summary.sourceCount = data.sources.length;
176
+ if (data?.imMessage?.message_id) summary.messageId = data.imMessage.message_id;
177
+ if (data?.imMessage?.local_message_id) summary.localMessageId = data.imMessage.local_message_id;
178
+ return summary;
179
+ }
180
+
156
181
  function sanitizeData(value, depth = 0, seen = new WeakSet()) {
157
182
  if (depth > 8) return '[MaxDepth]';
158
183
  if (value == null) return value;
package/src/constants.js CHANGED
@@ -15,7 +15,8 @@ export const Code = {
15
15
  AutomationFailed: 30010005,
16
16
  RunnerFailed: 30010006,
17
17
  AppiumUnavailable: 30010007,
18
- FridaUnavailable: 30010008
18
+ FridaUnavailable: 30010008,
19
+ AppNotInstalled: 30010009
19
20
  };
20
21
 
21
22
  export const Status = {
package/src/context.js CHANGED
@@ -8,7 +8,6 @@ const toolkitRoot = path.resolve(__dirname, '..');
8
8
  export const Context = {
9
9
  createAndroidContext,
10
10
  firstNonEmpty,
11
- trimRight,
12
11
  toolkitPath
13
12
  };
14
13
 
@@ -40,8 +39,6 @@ export function createAndroidContext(input = {}, defaults = {}) {
40
39
  defaults.fridaWebViewEventAgentScript,
41
40
  toolkitPath('src/internals/frida/webview_event_agent.js')
42
41
  ),
43
- appiumServerUrl: trimRight(firstNonEmpty(defaults.appiumServerUrl), '/'),
44
- appiumSessionId: firstNonEmpty(defaults.appiumSessionId),
45
42
  appVersion: firstNonEmpty(defaults.appVersion),
46
43
  slotKey: firstNonEmpty(defaults.slotKey),
47
44
  instanceId: firstNonEmpty(defaults.instanceId),
@@ -61,12 +58,6 @@ export function firstNonEmpty(...values) {
61
58
  return '';
62
59
  }
63
60
 
64
- export function trimRight(value, suffix) {
65
- let out = String(value || '').trim();
66
- while (suffix && out.endsWith(suffix)) out = out.slice(0, -suffix.length);
67
- return out;
68
- }
69
-
70
61
  function normalizeRuntimeDevice(value) {
71
62
  if (typeof value === 'string') return {serial: value};
72
63
  if (value && typeof value === 'object') return value;
package/src/device.js CHANGED
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import {execFile} from 'node:child_process';
3
3
  import {promisify} from 'node:util';
4
4
 
5
+ import {Code} from './constants.js';
6
+ import {CrawlerError} from './errors.js';
5
7
  import {Logger, sleep} from './logger.js';
6
8
 
7
9
  const execFileAsync = promisify(execFile);
@@ -14,35 +16,52 @@ export async function adbShell(ctx, args, options = {}) {
14
16
 
15
17
  export async function adbExec(ctx, args, options = {}) {
16
18
  // 执行原始 adb 命令;当云手机/TCP ADB 临时 offline 时,自动 adb connect 后重放一次。
19
+ const startedAt = Date.now();
20
+ Logger.debug('adb exec', {serial: ctx.serial, args: compactArgs(args), timeoutMs: Number(options.timeoutMs || 15000)});
17
21
  try {
18
22
  const {stdout} = await execFileAsync(ctx.adbPath, args, {
19
23
  timeout: Number(options.timeoutMs || 15000),
20
24
  maxBuffer: Number(options.maxBuffer || 12 * 1024 * 1024),
21
25
  encoding: options.encoding || 'utf8'
22
26
  });
27
+ Logger.debug('adb exec done', {serial: ctx.serial, duration: Logger.duration(startedAt)});
23
28
  return stdout;
24
29
  } catch (error) {
25
30
  // ADB over TCP 的云手机/云真机场景经常临时 offline。
26
31
  // 这里统一做一次 adb connect + 原命令重放,业务层不需要关心连接抖动。
27
- if (!isAdbOfflineError(error) || !ctx.serial) throw error;
32
+ if (!isAdbOfflineError(error) || !ctx.serial) {
33
+ if (isAdbUnavailableError(error)) throw makeAdbUnavailableError(ctx, args, error);
34
+ Logger.warn('adb exec failed', {serial: ctx.serial, args: compactArgs(args), message: error?.message || String(error)});
35
+ throw error;
36
+ }
37
+ Logger.warn('adb offline, reconnecting', {serial: ctx.serial, args: compactArgs(args)});
28
38
  await execFileAsync(ctx.adbPath, ['connect', ctx.serial], {timeout: 15000}).catch(() => { });
29
39
  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;
40
+ try {
41
+ const {stdout} = await execFileAsync(ctx.adbPath, args, {
42
+ timeout: Number(options.timeoutMs || 15000),
43
+ maxBuffer: Number(options.maxBuffer || 12 * 1024 * 1024),
44
+ encoding: options.encoding || 'utf8'
45
+ });
46
+ Logger.debug('adb exec retry done', {serial: ctx.serial, duration: Logger.duration(startedAt)});
47
+ return stdout;
48
+ } catch (retryError) {
49
+ if (isAdbUnavailableError(retryError)) throw makeAdbUnavailableError(ctx, args, retryError);
50
+ Logger.warn('adb exec retry failed', {serial: ctx.serial, args: compactArgs(args), message: retryError?.message || String(retryError)});
51
+ throw retryError;
52
+ }
36
53
  }
37
54
  }
38
55
 
39
56
  export async function forceStopApp(ctx, packageName = ctx.packageName) {
40
57
  // 强制关闭 App,用于每条任务开始前清理上一次页面状态。
58
+ Logger.info('force stop app', {serial: ctx.serial, packageName});
41
59
  await adbShell(ctx, ['am', 'force-stop', packageName]).catch(() => { });
42
60
  }
43
61
 
44
62
  export async function startActivity(ctx, component, args = []) {
45
63
  // 启动指定 Android Activity,component 形如 `com.tencent.mm/.ui.LauncherUI`。
64
+ Logger.info('start activity', {serial: ctx.serial, component});
46
65
  await adbShell(ctx, ['am', 'start', '-n', component, ...args]);
47
66
  await sleep(1800);
48
67
  }
@@ -55,11 +74,6 @@ export async function startLauncher(ctx, packageName = ctx.packageName, launcher
55
74
  ]);
56
75
  }
57
76
 
58
- export async function launchPackageByMonkey(ctx, packageName = ctx.packageName) {
59
- // launcher 启动失败时的兜底:让 Android 自己找包的默认入口。
60
- await adbShell(ctx, ['monkey', '-p', packageName, '1']);
61
- }
62
-
63
77
  export async function waitForForeground(ctx, packageName = ctx.packageName, timeoutMs = 6000) {
64
78
  // 轮询前台 Activity,直到目标包名进入前台或超时。
65
79
  const deadline = Date.now() + timeoutMs;
@@ -81,21 +95,6 @@ export async function screenSize(ctx) {
81
95
  };
82
96
  }
83
97
 
84
- export function px(ctx, ratio) {
85
- // 把横向比例坐标转成屏幕像素。
86
- return String(Math.floor((ctx.screen?.width || 720) * ratio));
87
- }
88
-
89
- export function py(ctx, ratio) {
90
- // 把纵向比例坐标转成屏幕像素。
91
- return String(Math.floor((ctx.screen?.height || 1280) * ratio));
92
- }
93
-
94
- export async function tapRatio(ctx, x, y) {
95
- // 点击比例坐标,适合不同分辨率设备共用同一套大致点位。
96
- await adbInput(ctx, ['tap', px(ctx, x), py(ctx, y)]);
97
- }
98
-
99
98
  export async function tapAbsolute(ctx, x, y) {
100
99
  // 点击绝对像素坐标,适合已经从截图或 DOM rect 算出的精确点。
101
100
  await adbInput(ctx, ['tap', String(Math.round(Number(x))), String(Math.round(Number(y)))]);
@@ -116,11 +115,6 @@ export async function click(ctx, pointOrX, y) {
116
115
  return tapAbsolute(ctx, pointOrX, y);
117
116
  }
118
117
 
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
118
  export async function move(ctx, from, to, durationMs = 500) {
125
119
  // ADB 没有鼠标 hover 概念,这里的 move 表示拖动/滑动手势。
126
120
  // from/to 使用屏幕绝对坐标,便于和截图/其他定位结果直接衔接。
@@ -139,35 +133,66 @@ export async function pressBack(ctx) {
139
133
  await adbInput(ctx, ['keyevent', 'BACK']).catch(() => { });
140
134
  }
141
135
 
142
- export async function pressEnter(ctx) {
143
- // 发送 Android 回车键,常用于提交搜索框。
144
- await adbInput(ctx, ['keyevent', 'ENTER']).catch(() => { });
136
+ export async function dismissPermissionDialog(ctx) {
137
+ // Android 运行时权限弹窗会把原 App 的 Activity 压在下面。
138
+ // 这里只处理系统 permission controller:点击底部“拒绝”按钮区域,避免采集任务卡死。
139
+ const focused = await focusedActivity(ctx).catch(() => '');
140
+ if (!String(focused || '').includes('GrantPermissionsActivity')) return false;
141
+ const size = await screenSize(ctx);
142
+ await tapAbsolute(ctx, size.width * 0.50, size.height * 0.63).catch(() => { });
143
+ await sleep(1000);
144
+ return true;
145
145
  }
146
146
 
147
147
  export async function wakeAndUnlock(ctx) {
148
148
  // 唤醒并解锁设备。真机/云手机在息屏时 dumpsys 可能仍保留上一个 App,
149
149
  // 业务层会误判前台 Activity,所以每条原生任务开始前都应该先恢复可交互屏幕。
150
+ Logger.info('wake and unlock', {serial: ctx.serial});
150
151
  await adbInput(ctx, ['keyevent', 'KEYCODE_WAKEUP']).catch(() => { });
151
152
  await sleep(500);
152
153
  await adbInput(ctx, ['keyevent', 'MENU']).catch(() => { });
153
154
  await sleep(500);
154
- await swipeRatio(ctx, 0.50, 0.82, 0.50, 0.25, 350).catch(() => { });
155
+ await swipeScreenRatio(ctx, 0.50, 0.82, 0.50, 0.25, 350).catch(() => { });
155
156
  await sleep(800);
156
157
  }
157
158
 
158
159
  export async function focusedActivity(ctx) {
160
+ // 优先从 activity manager 读取 resumed activity。`dumpsys window` 在微信 WebView
161
+ // 场景里可能同时保留旧窗口和新窗口,直接读 window focus 容易误判。
162
+ const resumed = await resumedActivity(ctx).catch(() => '');
163
+ if (resumed) return resumed;
164
+
159
165
  // 从 dumpsys window 中提取当前前台 Activity,用于替代 OCR 做页面状态判断。
160
166
  const out = await adbShell(ctx, ['dumpsys', 'window']);
161
167
  let fallback = '';
162
- let focused = '';
168
+ let currentFocus = '';
169
+ let appWindowFocus = '';
163
170
  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
- }
171
+ const trimmed = line.trim();
172
+ if (!trimmed.includes('mCurrentFocus') && !trimmed.includes('mFocusedApp')) continue;
173
+ if (!fallback) fallback = trimmed;
174
+ if (trimmed.includes('=null')) continue;
175
+ // dumpsys window 里会同时出现当前窗口和历史 token 行。
176
+ // 只信真实窗口焦点和 AppWindowToken,避免把旧 LauncherUI Token 当成当前页。
177
+ if (trimmed.includes('mCurrentFocus=Window')) currentFocus = trimmed;
178
+ else if (trimmed.includes('mFocusedApp=AppWindowToken')) appWindowFocus = trimmed;
169
179
  }
170
- return focused || fallback || String(out || '').trim();
180
+ return currentFocus || appWindowFocus || fallback || String(out || '').trim();
181
+ }
182
+
183
+ async function resumedActivity(ctx) {
184
+ const out = await adbShell(ctx, ['dumpsys', 'activity', 'activities'], {
185
+ timeoutMs: 15000,
186
+ maxBuffer: 12 * 1024 * 1024
187
+ });
188
+ let fallback = '';
189
+ for (const line of String(out || '').split('\n')) {
190
+ const trimmed = line.trim();
191
+ if (!trimmed.includes('ActivityRecord') || !trimmed.includes('/')) continue;
192
+ if (trimmed.includes('mTopResumedActivity') || trimmed.includes('topResumedActivity')) return trimmed;
193
+ if (trimmed.includes('mResumedActivity')) fallback = trimmed;
194
+ }
195
+ return fallback;
171
196
  }
172
197
 
173
198
  export function activityIncludes(focused, fragment) {
@@ -177,10 +202,15 @@ export function activityIncludes(focused, fragment) {
177
202
 
178
203
  export async function waitForActivity(ctx, predicate, timeoutMs = 15000, intervalMs = 700) {
179
204
  // 等待前台 Activity 满足调用方 predicate。
205
+ const startedAt = Date.now();
206
+ Logger.info('wait activity start', {serial: ctx.serial, timeoutMs, intervalMs});
180
207
  const deadline = Date.now() + timeoutMs;
181
208
  while (Date.now() < deadline) {
182
209
  const focused = await focusedActivity(ctx).catch(() => '');
183
- if (predicate(focused)) return focused;
210
+ if (predicate(focused)) {
211
+ Logger.info('wait activity matched', {serial: ctx.serial, duration: Logger.duration(startedAt), focused: summarizeText(focused, 160)});
212
+ return focused;
213
+ }
184
214
  await sleep(intervalMs);
185
215
  }
186
216
  throw new Error(`waitForActivity timeout: focused=${await focusedActivity(ctx).catch(() => '')}`);
@@ -249,20 +279,7 @@ export async function type(ctx, text) {
249
279
  }
250
280
 
251
281
  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));
263
- }
264
- }
265
- throw lastError;
282
+ await adbShell(ctx, ['input', ...args], options);
266
283
  }
267
284
 
268
285
  function isAdbOfflineError(error) {
@@ -270,12 +287,20 @@ function isAdbOfflineError(error) {
270
287
  return message.includes('device offline') || message.includes('device unauthorized');
271
288
  }
272
289
 
273
- function isRetryableAdbInputError(error) {
290
+ function isAdbUnavailableError(error) {
274
291
  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;
292
+ return message.includes('device not found') || message.includes('no devices/emulators found');
293
+ }
294
+
295
+ function makeAdbUnavailableError(ctx, args, error) {
296
+ return new CrawlerError({
297
+ message: `adb_unavailable: ${error?.message || String(error)}`,
298
+ code: Code.AdbUnavailable,
299
+ context: {
300
+ serial: ctx.serial,
301
+ args
302
+ }
303
+ });
279
304
  }
280
305
 
281
306
  function isBounds(value) {
@@ -287,6 +312,18 @@ function isBounds(value) {
287
312
  return [left, top, right, bottom].every(Number.isFinite) && right > left && bottom > top;
288
313
  }
289
314
 
315
+ async function swipeScreenRatio(ctx, fromX, fromY, toX, toY, durationMs = 500) {
316
+ const size = await screenSize(ctx).catch(() => ({width: 720, height: 1280}));
317
+ await adbInput(ctx, [
318
+ 'swipe',
319
+ String(Math.floor(size.width * fromX)),
320
+ String(Math.floor(size.height * fromY)),
321
+ String(Math.floor(size.width * toX)),
322
+ String(Math.floor(size.height * toY)),
323
+ String(durationMs)
324
+ ]);
325
+ }
326
+
290
327
  function safeFilePart(value) {
291
328
  return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '-');
292
329
  }
@@ -313,24 +350,29 @@ function adbInputTextForShell(raw) {
313
350
  return `''${escapedText}''`;
314
351
  }
315
352
 
353
+ function compactArgs(args) {
354
+ return (args || []).map((arg) => summarizeText(arg, 80)).join(' ');
355
+ }
356
+
357
+ function summarizeText(value, maxLen) {
358
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
359
+ if (text.length <= maxLen) return text;
360
+ return `${text.slice(0, maxLen)}...`;
361
+ }
362
+
316
363
  export const Device = {
317
364
  adbShell,
318
365
  adbExec,
319
366
  forceStopApp,
320
367
  startActivity,
321
368
  startLauncher,
322
- launchPackageByMonkey,
323
369
  waitForForeground,
324
370
  screenSize,
325
- px,
326
- py,
327
- tapRatio,
328
371
  tapAbsolute,
329
372
  click,
330
- swipeRatio,
331
373
  move,
332
374
  pressBack,
333
- pressEnter,
375
+ dismissPermissionDialog,
334
376
  wakeAndUnlock,
335
377
  focusedActivity,
336
378
  activityIncludes,