@skrillex1224/android-toolkit 0.1.0
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 +56 -0
- package/entrys/node.js +24 -0
- package/index.js +1 -0
- package/package.json +24 -0
- package/scripts/postinstall.js +72 -0
- package/src/apify-kit.js +177 -0
- package/src/constants.js +24 -0
- package/src/context.js +98 -0
- package/src/device.js +344 -0
- package/src/errors.js +37 -0
- package/src/frida-client.js +429 -0
- package/src/internals/frida/webview_event_agent.js +223 -0
- package/src/launch.js +52 -0
- package/src/logger.js +53 -0
package/src/device.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import {execFile} from 'node:child_process';
|
|
3
|
+
import {promisify} from 'node:util';
|
|
4
|
+
|
|
5
|
+
import {Logger, sleep} from './logger.js';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export async function adbShell(ctx, args, options = {}) {
|
|
10
|
+
// 在指定设备上执行 `adb shell ...`,所有设备侧命令都从这里收口。
|
|
11
|
+
if (!ctx.serial) throw new Error('device.serial is empty');
|
|
12
|
+
return adbExec(ctx, ['-s', ctx.serial, 'shell', ...args], options);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function adbExec(ctx, args, options = {}) {
|
|
16
|
+
// 执行原始 adb 命令;当云手机/TCP ADB 临时 offline 时,自动 adb connect 后重放一次。
|
|
17
|
+
try {
|
|
18
|
+
const {stdout} = await execFileAsync(ctx.adbPath, args, {
|
|
19
|
+
timeout: Number(options.timeoutMs || 15000),
|
|
20
|
+
maxBuffer: Number(options.maxBuffer || 12 * 1024 * 1024),
|
|
21
|
+
encoding: options.encoding || 'utf8'
|
|
22
|
+
});
|
|
23
|
+
return stdout;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
// ADB over TCP 的云手机/云真机场景经常临时 offline。
|
|
26
|
+
// 这里统一做一次 adb connect + 原命令重放,业务层不需要关心连接抖动。
|
|
27
|
+
if (!isAdbOfflineError(error) || !ctx.serial) throw error;
|
|
28
|
+
await execFileAsync(ctx.adbPath, ['connect', ctx.serial], {timeout: 15000}).catch(() => { });
|
|
29
|
+
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;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function forceStopApp(ctx, packageName = ctx.packageName) {
|
|
40
|
+
// 强制关闭 App,用于每条任务开始前清理上一次页面状态。
|
|
41
|
+
await adbShell(ctx, ['am', 'force-stop', packageName]).catch(() => { });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function startActivity(ctx, component, args = []) {
|
|
45
|
+
// 启动指定 Android Activity,component 形如 `com.tencent.mm/.ui.LauncherUI`。
|
|
46
|
+
await adbShell(ctx, ['am', 'start', '-n', component, ...args]);
|
|
47
|
+
await sleep(1800);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function startLauncher(ctx, packageName = ctx.packageName, launcherActivity = '.ui.LauncherUI') {
|
|
51
|
+
// 按 launcher 方式启动 App 首页;业务方只需要传包名和首页 Activity。
|
|
52
|
+
await startActivity(ctx, `${packageName}/${launcherActivity}`, [
|
|
53
|
+
'-a', 'android.intent.action.MAIN',
|
|
54
|
+
'-c', 'android.intent.category.LAUNCHER'
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function launchPackageByMonkey(ctx, packageName = ctx.packageName) {
|
|
59
|
+
// launcher 启动失败时的兜底:让 Android 自己找包的默认入口。
|
|
60
|
+
await adbShell(ctx, ['monkey', '-p', packageName, '1']);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function waitForForeground(ctx, packageName = ctx.packageName, timeoutMs = 6000) {
|
|
64
|
+
// 轮询前台 Activity,直到目标包名进入前台或超时。
|
|
65
|
+
const deadline = Date.now() + timeoutMs;
|
|
66
|
+
while (Date.now() < deadline) {
|
|
67
|
+
const focused = await focusedActivity(ctx).catch(() => '');
|
|
68
|
+
if (focused.includes(packageName)) return true;
|
|
69
|
+
await sleep(500);
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function screenSize(ctx) {
|
|
75
|
+
// 读取屏幕尺寸,失败时给一个常见竖屏默认值,避免后续坐标计算崩掉。
|
|
76
|
+
const out = await adbShell(ctx, ['wm', 'size']).catch(() => '');
|
|
77
|
+
const match = /(\d+)x(\d+)/.exec(String(out || ''));
|
|
78
|
+
return {
|
|
79
|
+
width: match ? Number(match[1]) : 720,
|
|
80
|
+
height: match ? Number(match[2]) : 1280
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
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
|
+
export async function tapAbsolute(ctx, x, y) {
|
|
100
|
+
// 点击绝对像素坐标,适合已经从截图或 DOM rect 算出的精确点。
|
|
101
|
+
await adbInput(ctx, ['tap', String(Math.round(Number(x))), String(Math.round(Number(y)))]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function click(ctx, pointOrX, y) {
|
|
105
|
+
// 对齐 Playwright 的直觉命名:业务代码可以写 Device.click(ctx, point),
|
|
106
|
+
// 也可以直接传 View/DOM bounds。toolkit 统一判断点击中心点。
|
|
107
|
+
if (pointOrX && typeof pointOrX === 'object') {
|
|
108
|
+
if (isBounds(pointOrX)) {
|
|
109
|
+
return tapAbsolute(ctx, (Number(pointOrX.left) + Number(pointOrX.right)) / 2, (Number(pointOrX.top) + Number(pointOrX.bottom)) / 2);
|
|
110
|
+
}
|
|
111
|
+
if (isBounds(pointOrX.bounds)) {
|
|
112
|
+
return tapAbsolute(ctx, (Number(pointOrX.bounds.left) + Number(pointOrX.bounds.right)) / 2, (Number(pointOrX.bounds.top) + Number(pointOrX.bounds.bottom)) / 2);
|
|
113
|
+
}
|
|
114
|
+
return tapAbsolute(ctx, pointOrX.x, pointOrX.y);
|
|
115
|
+
}
|
|
116
|
+
return tapAbsolute(ctx, pointOrX, y);
|
|
117
|
+
}
|
|
118
|
+
|
|
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
|
+
export async function move(ctx, from, to, durationMs = 500) {
|
|
125
|
+
// ADB 没有鼠标 hover 概念,这里的 move 表示拖动/滑动手势。
|
|
126
|
+
// from/to 使用屏幕绝对坐标,便于和截图/其他定位结果直接衔接。
|
|
127
|
+
await adbInput(ctx, [
|
|
128
|
+
'swipe',
|
|
129
|
+
String(Math.round(Number(from?.x))),
|
|
130
|
+
String(Math.round(Number(from?.y))),
|
|
131
|
+
String(Math.round(Number(to?.x))),
|
|
132
|
+
String(Math.round(Number(to?.y))),
|
|
133
|
+
String(durationMs)
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function pressBack(ctx) {
|
|
138
|
+
// 发送 Android 返回键。
|
|
139
|
+
await adbInput(ctx, ['keyevent', 'BACK']).catch(() => { });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function pressEnter(ctx) {
|
|
143
|
+
// 发送 Android 回车键,常用于提交搜索框。
|
|
144
|
+
await adbInput(ctx, ['keyevent', 'ENTER']).catch(() => { });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function wakeAndUnlock(ctx) {
|
|
148
|
+
// 唤醒并解锁设备。真机/云手机在息屏时 dumpsys 可能仍保留上一个 App,
|
|
149
|
+
// 业务层会误判前台 Activity,所以每条原生任务开始前都应该先恢复可交互屏幕。
|
|
150
|
+
await adbInput(ctx, ['keyevent', 'KEYCODE_WAKEUP']).catch(() => { });
|
|
151
|
+
await sleep(500);
|
|
152
|
+
await adbInput(ctx, ['keyevent', 'MENU']).catch(() => { });
|
|
153
|
+
await sleep(500);
|
|
154
|
+
await swipeRatio(ctx, 0.50, 0.82, 0.50, 0.25, 350).catch(() => { });
|
|
155
|
+
await sleep(800);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function focusedActivity(ctx) {
|
|
159
|
+
// 从 dumpsys window 中提取当前前台 Activity,用于替代 OCR 做页面状态判断。
|
|
160
|
+
const out = await adbShell(ctx, ['dumpsys', 'window']);
|
|
161
|
+
let fallback = '';
|
|
162
|
+
let focused = '';
|
|
163
|
+
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
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return focused || fallback || String(out || '').trim();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function activityIncludes(focused, fragment) {
|
|
174
|
+
// 小工具:判断当前 Activity 文本是否包含目标片段。
|
|
175
|
+
return String(focused || '').includes(fragment);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function waitForActivity(ctx, predicate, timeoutMs = 15000, intervalMs = 700) {
|
|
179
|
+
// 等待前台 Activity 满足调用方 predicate。
|
|
180
|
+
const deadline = Date.now() + timeoutMs;
|
|
181
|
+
while (Date.now() < deadline) {
|
|
182
|
+
const focused = await focusedActivity(ctx).catch(() => '');
|
|
183
|
+
if (predicate(focused)) return focused;
|
|
184
|
+
await sleep(intervalMs);
|
|
185
|
+
}
|
|
186
|
+
throw new Error(`waitForActivity timeout: focused=${await focusedActivity(ctx).catch(() => '')}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function screenshotBase64(ctx) {
|
|
190
|
+
// 获取当前屏幕 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
|
+
});
|
|
196
|
+
return Buffer.from(stdout).toString('base64');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function saveDebugScreenshot(ctx, name) {
|
|
200
|
+
// 保存调试截图到 /tmp,主要用于本地定位点击点和页面状态。
|
|
201
|
+
if (!ctx.serial) return '';
|
|
202
|
+
const safeRunId = safeFilePart(ctx.runId || 'manual');
|
|
203
|
+
const safeName = safeFilePart(name || 'screenshot');
|
|
204
|
+
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
|
+
});
|
|
210
|
+
fs.writeFileSync(out, stdout);
|
|
211
|
+
Logger.info(`debug screenshot saved ${out}`);
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function clearInputByDelete(ctx, count = 40) {
|
|
216
|
+
// 先把光标移到文本末尾再删除。微信搜索框里如果光标停在中间,
|
|
217
|
+
// 直接 DEL 会只删前半段,留下旧 query 尾巴污染下一次搜索。
|
|
218
|
+
await adbInput(ctx, ['keyevent', 'KEYCODE_MOVE_END']).catch(() => { });
|
|
219
|
+
await sleep(150);
|
|
220
|
+
for (let i = 0; i < count; i += 1) {
|
|
221
|
+
await adbInput(ctx, ['keyevent', 'DEL']).catch(() => { });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function typeText(ctx, text) {
|
|
226
|
+
// 输入中文文本。要求设备安装 Appium Settings,因为原生 adb input text 不支持可靠中文。
|
|
227
|
+
const value = String(text || '');
|
|
228
|
+
if (!value) return;
|
|
229
|
+
const unicodeIME = 'io.appium.settings/.UnicodeIME';
|
|
230
|
+
const previousIME = String(await adbShell(ctx, ['settings', 'get', 'secure', 'default_input_method']).catch(() => '')).trim();
|
|
231
|
+
try {
|
|
232
|
+
// 中文输入统一走 Appium Settings UnicodeIME 思路:
|
|
233
|
+
// 普通 `adb input text` 会把中文打成乱码;IMAP UTF-7 编码可以让 UnicodeIME
|
|
234
|
+
// 在 Android 输入法层还原真实中文,适用于任意支持 ADB 的真机/云手机。
|
|
235
|
+
await adbShell(ctx, ['ime', 'enable', unicodeIME]).catch(() => { });
|
|
236
|
+
await adbShell(ctx, ['ime', 'set', unicodeIME]);
|
|
237
|
+
await sleep(800);
|
|
238
|
+
await adbInput(ctx, ['text', adbInputTextForShell(imapUtf7Encode(value))]);
|
|
239
|
+
await sleep(1200);
|
|
240
|
+
} finally {
|
|
241
|
+
if (previousIME && previousIME !== 'null' && previousIME !== unicodeIME) {
|
|
242
|
+
await adbShell(ctx, ['ime', 'set', previousIME]).catch(() => { });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function type(ctx, text) {
|
|
248
|
+
return typeText(ctx, text);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
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;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isAdbOfflineError(error) {
|
|
269
|
+
const message = String(error?.message || error || '').toLowerCase();
|
|
270
|
+
return message.includes('device offline') || message.includes('device unauthorized');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isRetryableAdbInputError(error) {
|
|
274
|
+
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;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isBounds(value) {
|
|
282
|
+
if (!value || typeof value !== 'object') return false;
|
|
283
|
+
const left = Number(value.left);
|
|
284
|
+
const top = Number(value.top);
|
|
285
|
+
const right = Number(value.right);
|
|
286
|
+
const bottom = Number(value.bottom);
|
|
287
|
+
return [left, top, right, bottom].every(Number.isFinite) && right > left && bottom > top;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function safeFilePart(value) {
|
|
291
|
+
return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '-');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function imapUtf7Encode(value) {
|
|
295
|
+
return String(value || '')
|
|
296
|
+
.replace(/&/g, '&-')
|
|
297
|
+
.replace(/[^\x20-\x7e]+/g, (chunk) => `&${utf7EncodeChunk(chunk).replace(/\//g, ',')}-`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function utf7EncodeChunk(value) {
|
|
301
|
+
const buffer = Buffer.alloc(String(value).length * 2, 'ascii');
|
|
302
|
+
let offset = 0;
|
|
303
|
+
for (let i = 0; i < String(value).length; i += 1) {
|
|
304
|
+
const code = String(value).charCodeAt(i);
|
|
305
|
+
buffer[offset++] = code >> 8;
|
|
306
|
+
buffer[offset++] = code & 0xff;
|
|
307
|
+
}
|
|
308
|
+
return buffer.toString('base64').replace(/=+$/, '');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function adbInputTextForShell(raw) {
|
|
312
|
+
const escapedText = String(raw || '').replace(/[()<>|;&*\\~^"'$`]/g, '\\$&').replace(/ /g, '%s');
|
|
313
|
+
return `''${escapedText}''`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const Device = {
|
|
317
|
+
adbShell,
|
|
318
|
+
adbExec,
|
|
319
|
+
forceStopApp,
|
|
320
|
+
startActivity,
|
|
321
|
+
startLauncher,
|
|
322
|
+
launchPackageByMonkey,
|
|
323
|
+
waitForForeground,
|
|
324
|
+
screenSize,
|
|
325
|
+
px,
|
|
326
|
+
py,
|
|
327
|
+
tapRatio,
|
|
328
|
+
tapAbsolute,
|
|
329
|
+
click,
|
|
330
|
+
swipeRatio,
|
|
331
|
+
move,
|
|
332
|
+
pressBack,
|
|
333
|
+
pressEnter,
|
|
334
|
+
wakeAndUnlock,
|
|
335
|
+
focusedActivity,
|
|
336
|
+
activityIncludes,
|
|
337
|
+
waitForActivity,
|
|
338
|
+
screenshotBase64,
|
|
339
|
+
saveDebugScreenshot,
|
|
340
|
+
clearInputByDelete,
|
|
341
|
+
typeText,
|
|
342
|
+
type,
|
|
343
|
+
sleep
|
|
344
|
+
};
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Code } from './constants.js';
|
|
2
|
+
|
|
3
|
+
// Android 原生自动化使用的结构化错误。
|
|
4
|
+
// 设计上对齐 playwright-toolkit 的 CrawlerError:业务层可以只 throw 普通 Error,
|
|
5
|
+
// 也可以 throw CrawlerError 携带 code/context,pushFailed 会统一解析。
|
|
6
|
+
export class CrawlerError extends Error {
|
|
7
|
+
constructor(input, options = {}) {
|
|
8
|
+
const payload = typeof input === 'string' ? { message: input } : (input || {});
|
|
9
|
+
super(payload.message || 'Android crawler error', options);
|
|
10
|
+
this.name = 'CrawlerError';
|
|
11
|
+
this.code = payload.code || Code.UnknownError;
|
|
12
|
+
this.context = payload.context || {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static isCrawlerError(error) {
|
|
16
|
+
return error instanceof CrawlerError || error?.name === 'CrawlerError';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static from(error, fallback = {}) {
|
|
20
|
+
if (CrawlerError.isCrawlerError(error)) return error;
|
|
21
|
+
return new CrawlerError({
|
|
22
|
+
message: error?.message || String(error),
|
|
23
|
+
code: fallback.code || Code.UnknownError,
|
|
24
|
+
context: fallback.context || {}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function serializeError(error) {
|
|
30
|
+
if (!error) return { message: '' };
|
|
31
|
+
return {
|
|
32
|
+
name: error.name || 'Error',
|
|
33
|
+
message: error.message || String(error),
|
|
34
|
+
stack: error.stack || '',
|
|
35
|
+
code: error.code || ''
|
|
36
|
+
};
|
|
37
|
+
}
|