@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 +3 -2
- package/package.json +1 -1
- package/src/apify-kit.js +66 -41
- package/src/constants.js +2 -1
- package/src/context.js +0 -9
- package/src/device.js +111 -69
- package/src/frida-client.js +597 -159
- package/src/internals/frida/webview_event_agent.js +4 -0
- package/src/launch.js +25 -9
- package/src/logger.js +60 -2
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.
|
|
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` |
|
|
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
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
|
|
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
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
Logger.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
103
|
-
Logger.success('pushSuccess',
|
|
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
|
-
|
|
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
|
-
}
|
|
126
|
-
Logger.success('pushFailed',
|
|
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
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)
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
143
|
-
//
|
|
144
|
-
|
|
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
|
|
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
|
|
168
|
+
let currentFocus = '';
|
|
169
|
+
let appWindowFocus = '';
|
|
163
170
|
for (const line of String(out || '').split('\n')) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
|
290
|
+
function isAdbUnavailableError(error) {
|
|
274
291
|
const message = String(error?.message || error || '').toLowerCase();
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
375
|
+
dismissPermissionDialog,
|
|
334
376
|
wakeAndUnlock,
|
|
335
377
|
focusedActivity,
|
|
336
378
|
activityIncludes,
|