android2harmony 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 +407 -0
- package/agents/build-fixer.md +385 -0
- package/agents/code-reviewer.md +478 -0
- package/agents/logic-coder.md +212 -0
- package/agents/logic-context-builder.md +199 -0
- package/agents/scripts/hmos-precheck/arkts.mjs +534 -0
- package/agents/scripts/hmos-precheck/codelinter.mjs +362 -0
- package/agents/scripts/hmos-precheck/index.mjs +926 -0
- package/agents/scripts/platform_query.mjs +535 -0
- package/dist/index.js +179 -0
- package/dist/index.js.map +7 -0
- package/package.json +29 -0
- package/skills/hmos-batch-ui-align/SKILL.md +141 -0
- package/skills/hmos-batch-ui-align/references/conversion-procedure.md +217 -0
- package/skills/hmos-batch-ui-align/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +2533 -0
- package/skills/hmos-batch-ui-align/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +555 -0
- package/skills/hmos-batch-ui-align/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +117 -0
- package/skills/hmos-batch-ui-align/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md +648 -0
- package/skills/hmos-batch-ui-align/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md +2089 -0
- package/skills/hmos-batch-ui-align/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md +1033 -0
- package/skills/hmos-batch-ui-align/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md +1183 -0
- package/skills/hmos-batch-ui-align/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md +576 -0
- package/skills/hmos-batch-ui-align/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md +297 -0
- package/skills/hmos-batch-ui-align/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md +395 -0
- package/skills/hmos-batch-ui-align/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md +903 -0
- package/skills/hmos-batch-ui-align/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md +106 -0
- package/skills/hmos-batch-ui-align/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md +1178 -0
- package/skills/hmos-batch-ui-align/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md +911 -0
- package/skills/hmos-batch-ui-align/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md +355 -0
- package/skills/hmos-batch-ui-align/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md +11 -0
- package/skills/hmos-batch-ui-align/scripts/android_parse_fast.ts +1750 -0
- package/skills/hmos-convert-pipeline/SKILL.md +446 -0
- package/skills/hmos-fix-build-errors/SKILL.md +281 -0
- package/skills/hmos-fix-build-errors/references/arkts-strict-patterns.md +219 -0
- package/skills/hmos-fix-build-errors/references/known-patterns.md +157 -0
- package/skills/hmos-fix-build-errors/references/rdb-entity-pattern.md +131 -0
- package/skills/hmos-resources-convert/SKILL.md +654 -0
- package/skills/hmos-resources-convert/references/conversion-rules.md +663 -0
- package/skills/hmos-resources-convert/references/dependency-analysis-rules.md +388 -0
- package/skills/hmos-resources-convert/references/resource-mapping-rules.md +457 -0
- package/skills/hmos-resources-convert/references/xml-drawable-to-svg-rules.md +513 -0
- package/skills/hmos-resources-convert/template/AppScope/app.json5 +10 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +8 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +7 -0
- package/skills/hmos-resources-convert/template/build-profile.json5 +42 -0
- package/skills/hmos-resources-convert/template/code-linter.json5 +32 -0
- package/skills/hmos-resources-convert/template/entry/build-profile.json5 +33 -0
- package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +6 -0
- package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +23 -0
- package/skills/hmos-resources-convert/template/entry/oh-package.json5 +10 -0
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +48 -0
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +16 -0
- package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +23 -0
- package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +55 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +8 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +8 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +16 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +7 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +3 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +5 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +8 -0
- package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +2 -0
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +35 -0
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +5 -0
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +16 -0
- package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +5 -0
- package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +33 -0
- package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +23 -0
- package/skills/hmos-resources-convert/template/hvigorfile.ts +6 -0
- package/skills/hmos-resources-convert/template/oh-package-lock.json5 +28 -0
- package/skills/hmos-resources-convert/template/oh-package.json5 +10 -0
- package/skills/hmos-spec-generate/SKILL.md +413 -0
- package/skills/hmos-spec-generate/references/android-platform-tokens.md +105 -0
- package/skills/hmos-spec-generate/references/spec-sample-1.md +78 -0
- package/skills/hmos-spec-generate/references/spec-sample-2.md +58 -0
- package/skills/hmos-spec-generate/references/spec-sample-3.md +116 -0
- package/skills/hmos-spec-generate/references/step4-report-template.md +33 -0
|
@@ -0,0 +1,1750 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Android ๆจกๆๅจ้กต้ข้ๅๅทฅๅ
ท๏ผๅ ้็, TypeScript ็๏ผ
|
|
4
|
+
* ๅ่ฝ๏ผBFS ้ๅๆฏไธช้กต้ข๏ผไฟๅญๆชๅพใView XMLใActivity/Window ไฟกๆฏ๏ผ็ๆ index.json + report.htmlใ
|
|
5
|
+
*
|
|
6
|
+
* ้ถ็ฌฌไธๆนไพ่ต๏ผไป
ไฝฟ็จ Node ๅ
็ฝฎๆจกๅ๏ผๅฏ็ฌ็ซๅๅใ
|
|
7
|
+
* ็ญไปทไบๅ android_parse_fast.py๏ผfunctional equivalence๏ผใ
|
|
8
|
+
*
|
|
9
|
+
* ็จๆณ:
|
|
10
|
+
* node android_parse_fast.ts --package <pkg> --output <dir> [--device serial] [--adb-path adb]
|
|
11
|
+
* [--max-pages 500] [--max-scrolls 10] [--manual]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import * as readline from 'node:readline';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
22
|
+
// ้ป่ฎค้
็ฝฎๅผ
|
|
23
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
24
|
+
const ADB_PATH_DEFAULT = 'adb';
|
|
25
|
+
const DEVICE_SERIAL_DEFAULT = 'emulator-5554';
|
|
26
|
+
const IDLE_POLL_INTERVAL_DEFAULT = 0.2;
|
|
27
|
+
const IDLE_TIMEOUT_DEFAULT = 5;
|
|
28
|
+
const MAX_PAGES_DEFAULT = 500;
|
|
29
|
+
const MAX_SCROLLS_DEFAULT = 10;
|
|
30
|
+
const SCROLL_DURATION_MS_DEFAULT = 300;
|
|
31
|
+
const SKIP_RESOURCE_ID_PATTERNS_DEFAULT = [
|
|
32
|
+
'statusBarBackground',
|
|
33
|
+
'navigationBarBackground',
|
|
34
|
+
'action_bar_container',
|
|
35
|
+
'status_bar',
|
|
36
|
+
'navigation_bar',
|
|
37
|
+
];
|
|
38
|
+
const HORIZONTAL_SCROLL_CLASSES = new Set(['android.widget.HorizontalScrollView']);
|
|
39
|
+
|
|
40
|
+
function sleep(seconds: number): void {
|
|
41
|
+
const end = Date.now() + seconds * 1000;
|
|
42
|
+
const ia = new Int32Array(new SharedArrayBuffer(4));
|
|
43
|
+
while (Date.now() < end) Atomics.wait(ia, 0, 0, Math.min(50, end - Date.now()));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
47
|
+
// ๆๅฐ XML ่งฃๆๅจ๏ผ็ญไปท xml.etree ็ root.iter("node") + get ๅฑๆง๏ผ
|
|
48
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
49
|
+
interface XmlNode {
|
|
50
|
+
tag: string;
|
|
51
|
+
attrs: Record<string, string>;
|
|
52
|
+
children: XmlNode[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const XML_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
56
|
+
|
|
57
|
+
function decodeEntities(s: string): string {
|
|
58
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, ent: string) => {
|
|
59
|
+
if (ent[0] === '#') {
|
|
60
|
+
const code = ent[1] === 'x' || ent[1] === 'X' ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10);
|
|
61
|
+
return Number.isNaN(code) ? m : String.fromCodePoint(code);
|
|
62
|
+
}
|
|
63
|
+
return ent in XML_ENTITIES ? XML_ENTITIES[ent] : m;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class XmlParseError extends Error {}
|
|
68
|
+
|
|
69
|
+
function parseXml(xml: string): XmlNode {
|
|
70
|
+
let i = 0;
|
|
71
|
+
const n = xml.length;
|
|
72
|
+
const stack: XmlNode[] = [];
|
|
73
|
+
let root: XmlNode | null = null;
|
|
74
|
+
|
|
75
|
+
const readAttrs = (raw: string): Record<string, string> => {
|
|
76
|
+
const attrs: Record<string, string> = {};
|
|
77
|
+
const re = /([^\s=/>]+)\s*=\s*"([^"]*)"|([^\s=/>]+)\s*=\s*'([^']*)'/g;
|
|
78
|
+
let m: RegExpExecArray | null;
|
|
79
|
+
while ((m = re.exec(raw)) !== null) {
|
|
80
|
+
if (m[1] !== undefined) attrs[m[1]] = decodeEntities(m[2]);
|
|
81
|
+
else if (m[3] !== undefined) attrs[m[3]] = decodeEntities(m[4]);
|
|
82
|
+
}
|
|
83
|
+
return attrs;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
while (i < n) {
|
|
87
|
+
const lt = xml.indexOf('<', i);
|
|
88
|
+
if (lt < 0) break;
|
|
89
|
+
i = lt;
|
|
90
|
+
if (xml.startsWith('<?', i)) {
|
|
91
|
+
const end = xml.indexOf('?>', i);
|
|
92
|
+
if (end < 0) throw new XmlParseError('unterminated declaration');
|
|
93
|
+
i = end + 2;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (xml.startsWith('<!--', i)) {
|
|
97
|
+
const end = xml.indexOf('-->', i);
|
|
98
|
+
if (end < 0) throw new XmlParseError('unterminated comment');
|
|
99
|
+
i = end + 3;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (xml.startsWith('<!', i)) {
|
|
103
|
+
const end = xml.indexOf('>', i);
|
|
104
|
+
if (end < 0) throw new XmlParseError('unterminated markup declaration');
|
|
105
|
+
i = end + 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let j = i + 1;
|
|
109
|
+
let quote: string | null = null;
|
|
110
|
+
for (; j < n; j++) {
|
|
111
|
+
const c = xml[j];
|
|
112
|
+
if (quote) {
|
|
113
|
+
if (c === quote) quote = null;
|
|
114
|
+
} else if (c === '"' || c === "'") quote = c;
|
|
115
|
+
else if (c === '>') break;
|
|
116
|
+
}
|
|
117
|
+
if (j >= n) throw new XmlParseError('unterminated tag');
|
|
118
|
+
const inner = xml.slice(i + 1, j);
|
|
119
|
+
i = j + 1;
|
|
120
|
+
if (inner[0] === '/') {
|
|
121
|
+
if (!stack.pop()) throw new XmlParseError('unexpected closing tag');
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const selfClosing = inner.endsWith('/');
|
|
125
|
+
const body = selfClosing ? inner.slice(0, -1) : inner;
|
|
126
|
+
const spaceIdx = body.search(/[\s/]/);
|
|
127
|
+
const tag = spaceIdx < 0 ? body : body.slice(0, spaceIdx);
|
|
128
|
+
const attrRaw = spaceIdx < 0 ? '' : body.slice(spaceIdx);
|
|
129
|
+
const node: XmlNode = { tag, attrs: readAttrs(attrRaw), children: [] };
|
|
130
|
+
if (stack.length > 0) stack[stack.length - 1].children.push(node);
|
|
131
|
+
else if (root === null) root = node;
|
|
132
|
+
if (!selfClosing) stack.push(node);
|
|
133
|
+
}
|
|
134
|
+
if (root === null) throw new XmlParseError('no root element');
|
|
135
|
+
return root;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function* iterNodes(root: XmlNode, tag: string): Generator<XmlNode> {
|
|
139
|
+
if (root.tag === tag) yield root;
|
|
140
|
+
for (const child of root.children) yield* iterNodes(child, tag);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function attr(node: XmlNode, key: string, dflt = ''): string {
|
|
144
|
+
return key in node.attrs ? node.attrs[key] : dflt;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractInts(s: string): number[] {
|
|
148
|
+
const m = s.match(/\d+/g);
|
|
149
|
+
return m ? m.map((x) => parseInt(x, 10)) : [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
153
|
+
// ๅ
็ด /็ญพๅ ็บฏๅฝๆฐ๏ผ็ญไปท PageCrawler ็ @staticmethod๏ผ
|
|
154
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
155
|
+
interface ClickableElement {
|
|
156
|
+
class: string;
|
|
157
|
+
text: string;
|
|
158
|
+
'resource-id': string;
|
|
159
|
+
'content-desc': string;
|
|
160
|
+
bounds: string;
|
|
161
|
+
center: [number, number] | null;
|
|
162
|
+
'long-clickable': boolean;
|
|
163
|
+
scroll_index?: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface ScrollContainer {
|
|
167
|
+
bounds: [number, number, number, number];
|
|
168
|
+
class: string;
|
|
169
|
+
'resource-id': string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseBoundsCenter(bounds: string): [number, number] | null {
|
|
173
|
+
const nums = extractInts(bounds);
|
|
174
|
+
if (nums.length === 4) {
|
|
175
|
+
const [x1, y1, x2, y2] = nums;
|
|
176
|
+
return [Math.trunc((x1 + x2) / 2), Math.trunc((y1 + y2) / 2)];
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function getClickableElements(xmlContent: string): ClickableElement[] {
|
|
182
|
+
const elements: ClickableElement[] = [];
|
|
183
|
+
if (!xmlContent) return elements;
|
|
184
|
+
let root: XmlNode;
|
|
185
|
+
try {
|
|
186
|
+
root = parseXml(xmlContent);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.log(` [WARN] XML ่งฃๆๅคฑ่ดฅ: ${(e as Error).message}`);
|
|
189
|
+
return elements;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const listviewChildSet = new Set<XmlNode>();
|
|
193
|
+
for (const node of iterNodes(root, 'node')) {
|
|
194
|
+
const cls = attr(node, 'class');
|
|
195
|
+
if (cls.includes('ListView') || cls.includes('GridView')) {
|
|
196
|
+
for (const child of node.children) listviewChildSet.add(child);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const extractText = (node: XmlNode): string => {
|
|
201
|
+
const t = attr(node, 'text');
|
|
202
|
+
if (t) return t;
|
|
203
|
+
for (const child of iterNodes(node, 'node')) {
|
|
204
|
+
const ct = attr(child, 'text');
|
|
205
|
+
if (ct) return ct;
|
|
206
|
+
}
|
|
207
|
+
return '';
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
for (const node of iterNodes(root, 'node')) {
|
|
211
|
+
if (attr(node, 'enabled') !== 'true') continue;
|
|
212
|
+
const isClickable = attr(node, 'clickable') === 'true';
|
|
213
|
+
const isLongClickable = attr(node, 'long-clickable') === 'true';
|
|
214
|
+
const isListviewItem = listviewChildSet.has(node);
|
|
215
|
+
const isCheckable = attr(node, 'checkable') === 'true';
|
|
216
|
+
if (!(isClickable || isLongClickable || isListviewItem || isCheckable)) continue;
|
|
217
|
+
const bounds = attr(node, 'bounds');
|
|
218
|
+
let text = attr(node, 'text');
|
|
219
|
+
if (!text) text = extractText(node);
|
|
220
|
+
const baseInfo = {
|
|
221
|
+
class: attr(node, 'class'),
|
|
222
|
+
text,
|
|
223
|
+
'resource-id': attr(node, 'resource-id'),
|
|
224
|
+
'content-desc': attr(node, 'content-desc'),
|
|
225
|
+
bounds,
|
|
226
|
+
center: parseBoundsCenter(bounds),
|
|
227
|
+
};
|
|
228
|
+
if (isClickable || isListviewItem || isCheckable) elements.push({ ...baseInfo, 'long-clickable': false });
|
|
229
|
+
if (isLongClickable) elements.push({ ...baseInfo, 'long-clickable': true });
|
|
230
|
+
}
|
|
231
|
+
return elements;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function extractNodeKeys(xmlContent: string): Set<string> {
|
|
235
|
+
const keys = new Set<string>();
|
|
236
|
+
if (!xmlContent) return keys;
|
|
237
|
+
let root: XmlNode;
|
|
238
|
+
try {
|
|
239
|
+
root = parseXml(xmlContent);
|
|
240
|
+
} catch {
|
|
241
|
+
return keys;
|
|
242
|
+
}
|
|
243
|
+
for (const node of iterNodes(root, 'node')) {
|
|
244
|
+
keys.add(
|
|
245
|
+
[attr(node, 'class'), attr(node, 'resource-id'), attr(node, 'text'), attr(node, 'content-desc'), attr(node, 'bounds')].join(''),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return keys;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
|
252
|
+
if (a.size !== b.size) return false;
|
|
253
|
+
for (const v of a) if (!b.has(v)) return false;
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function findScrollableContainers(xmlContent: string): ScrollContainer[] {
|
|
258
|
+
const containers: ScrollContainer[] = [];
|
|
259
|
+
if (!xmlContent) return containers;
|
|
260
|
+
let root: XmlNode;
|
|
261
|
+
try {
|
|
262
|
+
root = parseXml(xmlContent);
|
|
263
|
+
} catch {
|
|
264
|
+
return containers;
|
|
265
|
+
}
|
|
266
|
+
for (const node of iterNodes(root, 'node')) {
|
|
267
|
+
if (attr(node, 'scrollable') !== 'true') continue;
|
|
268
|
+
const cls = attr(node, 'class');
|
|
269
|
+
if (HORIZONTAL_SCROLL_CLASSES.has(cls)) continue;
|
|
270
|
+
const nums = extractInts(attr(node, 'bounds'));
|
|
271
|
+
if (nums.length !== 4) continue;
|
|
272
|
+
const [x1, y1, x2, y2] = nums;
|
|
273
|
+
if (y2 - y1 < 200) continue;
|
|
274
|
+
containers.push({ bounds: [x1, y1, x2, y2], class: cls, 'resource-id': attr(node, 'resource-id') });
|
|
275
|
+
}
|
|
276
|
+
containers.sort(
|
|
277
|
+
(a, b) => (b.bounds[2] - b.bounds[0]) * (b.bounds[3] - b.bounds[1]) - (a.bounds[2] - a.bounds[0]) * (a.bounds[3] - a.bounds[1]),
|
|
278
|
+
);
|
|
279
|
+
return containers;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function normalizeComponent(component: string): string {
|
|
283
|
+
if (!component.includes('/')) return component;
|
|
284
|
+
const idx = component.indexOf('/');
|
|
285
|
+
const pkg = component.slice(0, idx);
|
|
286
|
+
let cls = component.slice(idx + 1);
|
|
287
|
+
if (cls.startsWith('.')) cls = pkg + cls;
|
|
288
|
+
return `${pkg}/${cls}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function stableActivityKey(activityInfo: Record<string, string>): string {
|
|
292
|
+
const focus = activityInfo['current_focus'] ?? '';
|
|
293
|
+
const resumed = activityInfo['resumed_activity'] ?? '';
|
|
294
|
+
const m = focus.match(/[\w.]+\/[\w.]+/);
|
|
295
|
+
if (m) return normalizeComponent(m[0]);
|
|
296
|
+
const actM = resumed.match(/[\w.]+\/[\w.]+/);
|
|
297
|
+
const actPart = actM ? normalizeComponent(actM[0]) : 'unknown';
|
|
298
|
+
const winTypeM = focus.match(/(PopupWindow|Dialog|DecorView|Toast)/);
|
|
299
|
+
const winType = winTypeM ? winTypeM[1] : 'overlay';
|
|
300
|
+
return `${actPart}|${winType}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function pageSignature(activityInfo: Record<string, string>, xmlContent: string): string {
|
|
304
|
+
const actKey = stableActivityKey(activityInfo);
|
|
305
|
+
if (!xmlContent) return `${actKey}|empty`;
|
|
306
|
+
const clickable = getClickableElements(xmlContent);
|
|
307
|
+
if (clickable.length === 0) return `${actKey}|no_clickable`;
|
|
308
|
+
const elemFeatures = clickable.map((e) =>
|
|
309
|
+
[e.class ?? '', e['resource-id'] ?? '', e['content-desc'] ?? '', e.bounds ?? ''].join('|'),
|
|
310
|
+
);
|
|
311
|
+
const featuresStr = elemFeatures.join(';');
|
|
312
|
+
const featureHash = createHash('md5').update(featuresStr, 'utf-8').digest('hex').slice(0, 16);
|
|
313
|
+
return `${actKey}|${featureHash}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
type RootIdentity = [string, Set<string>];
|
|
317
|
+
|
|
318
|
+
function rootIdentifiedSet(activityInfo: Record<string, string>, xmlContent: string): RootIdentity {
|
|
319
|
+
const resumed = activityInfo['resumed_activity'] ?? '';
|
|
320
|
+
const m = resumed.match(/[\w.]+\/[\w.]+/);
|
|
321
|
+
const actKey = m ? normalizeComponent(m[0]) : 'unknown';
|
|
322
|
+
if (!xmlContent) return [actKey, new Set()];
|
|
323
|
+
const clickable = getClickableElements(xmlContent);
|
|
324
|
+
const elems = new Set<string>();
|
|
325
|
+
for (const e of clickable) {
|
|
326
|
+
const desc = e['content-desc'] ?? '';
|
|
327
|
+
const rid = e['resource-id'] ?? '';
|
|
328
|
+
if (!desc && !rid) continue;
|
|
329
|
+
elems.add([e.class ?? '', rid, desc].join(''));
|
|
330
|
+
}
|
|
331
|
+
return [actKey, elems];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isSameRootPage(rootInfo: RootIdentity, currentInfo: RootIdentity, threshold = 0.6): boolean {
|
|
335
|
+
const [rootAct, rootElems] = rootInfo;
|
|
336
|
+
const [curAct, curElems] = currentInfo;
|
|
337
|
+
if (rootAct !== curAct) return false;
|
|
338
|
+
if (rootElems.size === 0 && curElems.size === 0) return true;
|
|
339
|
+
if (rootElems.size === 0 || curElems.size === 0) return false;
|
|
340
|
+
let inter = 0;
|
|
341
|
+
for (const v of rootElems) if (curElems.has(v)) inter++;
|
|
342
|
+
const union = rootElems.size + curElems.size - inter;
|
|
343
|
+
return union > 0 && inter / union >= threshold;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** current_focus ๆฏๅฆๅซ `package/activity` ๅ้๏ผๅผน็ช/overlay ็ฆ็นๆฒกๆ๏ผใ */
|
|
347
|
+
function focusHasActivityComponent(activityInfo: Record<string, string>): boolean {
|
|
348
|
+
return /[\w.]+\/[\w.]+/.test(activityInfo['current_focus'] ?? '');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* ไป activity ไฟกๆฏๆๅๅฏๅจ็ปไปถ [package, activity]ใ
|
|
353
|
+
* ไผๅ
current_focus๏ผ้ๅธธๆฏๅ
จ้ๅฎ Activity๏ผ๏ผๅ้ resumed_activity๏ผๅฏ่ฝๆฏ็ธๅฏนๅ `.ui.Main`๏ผ
|
|
354
|
+
* am start ๅๆ ทๆฅๅ๏ผใ้ฝๅไธๅฐ่ฟๅ nullใ
|
|
355
|
+
*/
|
|
356
|
+
function extractLaunchComponent(activityInfo: Record<string, string>): [string, string] | null {
|
|
357
|
+
for (const key of ['current_focus', 'resumed_activity']) {
|
|
358
|
+
const s = activityInfo[key] ?? '';
|
|
359
|
+
const m = s.match(/([\w.]+)\/([\w.]+)/);
|
|
360
|
+
if (m) return [m[1], m[2]];
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
type ElemId = [string, string, string, string, boolean];
|
|
366
|
+
|
|
367
|
+
function findElementOnScreen(xmlContent: string, elemId: ElemId | [string, string, string, string]): ClickableElement | null {
|
|
368
|
+
const rid = elemId[0];
|
|
369
|
+
const text = elemId[1];
|
|
370
|
+
const desc = elemId[2];
|
|
371
|
+
const bounds = elemId[3];
|
|
372
|
+
const isLong = elemId.length >= 5 ? (elemId[4] as boolean) : null;
|
|
373
|
+
const clickableElements = getClickableElements(xmlContent);
|
|
374
|
+
|
|
375
|
+
if (bounds) {
|
|
376
|
+
for (const e of clickableElements) {
|
|
377
|
+
if (
|
|
378
|
+
e['resource-id'] === rid &&
|
|
379
|
+
e.text === text &&
|
|
380
|
+
e['content-desc'] === desc &&
|
|
381
|
+
e.bounds === bounds &&
|
|
382
|
+
(isLong === null || e['long-clickable'] === isLong)
|
|
383
|
+
) {
|
|
384
|
+
return e;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const e of clickableElements) {
|
|
389
|
+
if (
|
|
390
|
+
e['resource-id'] === rid &&
|
|
391
|
+
e.text === text &&
|
|
392
|
+
e['content-desc'] === desc &&
|
|
393
|
+
(isLong === null || e['long-clickable'] === isLong)
|
|
394
|
+
) {
|
|
395
|
+
return e;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
402
|
+
// ADBHelper๏ผๅฐ่ฃ
ๆๆ ADB ่ฎพๅค้ไฟกไธไบคไบๆไฝ
|
|
403
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
404
|
+
class ADBHelper {
|
|
405
|
+
adbPath: string;
|
|
406
|
+
deviceSerial: string | null;
|
|
407
|
+
idlePollInterval: number;
|
|
408
|
+
idleTimeout: number;
|
|
409
|
+
scrollDurationMs: number;
|
|
410
|
+
/** ็ฎๆ App ๅ
ๅ๏ผๅคๅฑ่ฎพๅค๏ผ่ฝฆๆบ็ญ๏ผไธ็จไบไปๅคๆก focus/resumed ่กไธญๆๅบๆญฃ็กฎ็้ฃๆกใ */
|
|
411
|
+
targetPackage: string | null;
|
|
412
|
+
/** screencap -d ็จ็็ฉ็ display id ็ผๅญ๏ผundefined=ๆช่งฃๆ๏ผnull=่งฃๆๅคฑ่ดฅ๏ผ้ๅๆ -d๏ผใ */
|
|
413
|
+
private cachedPhysicalDisplayId: string | null | undefined = undefined;
|
|
414
|
+
|
|
415
|
+
constructor(opts: {
|
|
416
|
+
adbPath?: string;
|
|
417
|
+
deviceSerial?: string | null;
|
|
418
|
+
idlePollInterval?: number;
|
|
419
|
+
idleTimeout?: number;
|
|
420
|
+
scrollDurationMs?: number;
|
|
421
|
+
targetPackage?: string | null;
|
|
422
|
+
} = {}) {
|
|
423
|
+
this.adbPath = opts.adbPath ?? ADB_PATH_DEFAULT;
|
|
424
|
+
this.deviceSerial = opts.deviceSerial ?? DEVICE_SERIAL_DEFAULT;
|
|
425
|
+
this.targetPackage = opts.targetPackage ?? null;
|
|
426
|
+
this.idlePollInterval = opts.idlePollInterval ?? IDLE_POLL_INTERVAL_DEFAULT;
|
|
427
|
+
this.idleTimeout = opts.idleTimeout ?? IDLE_TIMEOUT_DEFAULT;
|
|
428
|
+
this.scrollDurationMs = opts.scrollDurationMs ?? SCROLL_DURATION_MS_DEFAULT;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private buildArgs(args: string[]): string[] {
|
|
432
|
+
const full: string[] = [];
|
|
433
|
+
if (this.deviceSerial) full.push('-s', this.deviceSerial);
|
|
434
|
+
full.push(...args);
|
|
435
|
+
return full;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private spawn(args: string[], timeout: number, binary: boolean): SpawnSyncReturns<Buffer> {
|
|
439
|
+
return spawnSync(this.adbPath, this.buildArgs(args), {
|
|
440
|
+
timeout: timeout * 1000,
|
|
441
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
442
|
+
windowsHide: true,
|
|
443
|
+
}) as SpawnSyncReturns<Buffer>;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
run(args: string[], timeout = 15): string {
|
|
447
|
+
const res = this.spawn(args, timeout, false);
|
|
448
|
+
if (res.error) {
|
|
449
|
+
const code = (res.error as NodeJS.ErrnoException).code;
|
|
450
|
+
if (code === 'ETIMEDOUT') {
|
|
451
|
+
console.log(` [WARN] adb ๅฝไปค่ถ
ๆถ: ${args.join(' ')}`);
|
|
452
|
+
return '';
|
|
453
|
+
}
|
|
454
|
+
if (code === 'ENOENT') {
|
|
455
|
+
throw new Error('ๆพไธๅฐ adb๏ผ่ฏท็กฎ่ฎคๅทฒๅฎ่ฃ
ๅนถ้
็ฝฎ PATH๏ผๆไฟฎๆน ADB_PATH');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const out = res.stdout ?? Buffer.alloc(0);
|
|
459
|
+
return out.toString('utf-8').trim();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
shell(args: string[], timeout = 15): string {
|
|
463
|
+
return this.run(['shell', ...args], timeout);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
waitForIdle(): void {
|
|
467
|
+
const deadline = Date.now() + this.idleTimeout * 1000;
|
|
468
|
+
let prevFocus: string | null = null;
|
|
469
|
+
let interval = this.idlePollInterval;
|
|
470
|
+
while (Date.now() < deadline) {
|
|
471
|
+
sleep(interval);
|
|
472
|
+
interval = Math.min(interval * 1.5, 0.5);
|
|
473
|
+
const transitionOut = this.shell(['dumpsys', 'window', 'animator']);
|
|
474
|
+
if (transitionOut.includes('Transition ready') || transitionOut.includes('Running animations')) continue;
|
|
475
|
+
const focusOut = this.shell(['dumpsys', 'window']);
|
|
476
|
+
// ๅคๅฑ่ฎพๅคไธๆฏไธช display ๅๆไธๆก mCurrentFocus๏ผๅ
จ้จๆผ่ตทๆฅๅ็จณๅฎๆงๆฏ่พ๏ผ
|
|
477
|
+
// ้ฟๅ
ๅช็ๅฏๅฑ้ฃๆก๏ผๅธธไธบ null๏ผๆฐธ่ฟ"็จณๅฎ"๏ผ่่ฏฏๅค UI ๅทฒ็ฉบ้ฒใ
|
|
478
|
+
const currentFocus = focusOut
|
|
479
|
+
.split(/\r?\n/)
|
|
480
|
+
.filter((line) => line.includes('mCurrentFocus'))
|
|
481
|
+
.map((line) => line.trim())
|
|
482
|
+
.join(' | ');
|
|
483
|
+
if (currentFocus && currentFocus === prevFocus) return;
|
|
484
|
+
prevFocus = currentFocus;
|
|
485
|
+
}
|
|
486
|
+
console.log(` [WARN] ็ญๅพ
UI ็ฉบ้ฒ่ถ
ๆถ (${this.idleTimeout}s)๏ผ็ปง็ปญๆง่ก`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
checkDevice(): boolean {
|
|
490
|
+
const out = this.run(['devices']);
|
|
491
|
+
const lines = out.split(/\r?\n/).filter((l) => l.trim() && !l.includes('List of devices'));
|
|
492
|
+
const online = lines.filter((l) => l.includes('device') && !l.includes('offline'));
|
|
493
|
+
if (online.length === 0) {
|
|
494
|
+
console.log(' ๆชๆฃๆตๅฐๅจ็บฟ่ฎพๅค๏ผ่ฏทๅ
ๅฏๅจๆจกๆๅจๅนถ็กฎ่ฎค adb devices ๆ่พๅบ');
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
const onlineDevices = online.map((line) => line.split(/\s+/)[0]);
|
|
498
|
+
console.log(` ๅจ็บฟ่ฎพๅค: [${onlineDevices.map((d) => `'${d}'`).join(', ')}]`);
|
|
499
|
+
if (this.deviceSerial === null) {
|
|
500
|
+
this.deviceSerial = onlineDevices[0];
|
|
501
|
+
console.log(` ๆชๆๅฎ่ฎพๅค๏ผ่ชๅจ้ๆฉ: ${this.deviceSerial}`);
|
|
502
|
+
} else if (!onlineDevices.includes(this.deviceSerial)) {
|
|
503
|
+
console.log(` ๆๅฎ็่ฎพๅค ${this.deviceSerial} ไธๅญๅจ๏ผ่ชๅจ้ๆฉ: ${onlineDevices[0]}`);
|
|
504
|
+
this.deviceSerial = onlineDevices[0];
|
|
505
|
+
} else {
|
|
506
|
+
console.log(` ไฝฟ็จๆๅฎ็่ฎพๅค: ${this.deviceSerial}`);
|
|
507
|
+
}
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* ไปๅคๆกๅ้่กไธญๆไธๆกๆๅฏไฟก็ใ
|
|
513
|
+
* ๅคๅฑ่ฎพๅค๏ผ่ฝฆๆบ / ๆๅ ๅฑ / cluster+ไธปๅฑ๏ผไธ๏ผ`dumpsys window` ไธ
|
|
514
|
+
* `dumpsys activity activities` ไผไธบ**ๆฏไธช display ๅ่พๅบไธๆก**
|
|
515
|
+
* mCurrentFocus / topResumedActivity๏ผๅ
ถไธญๅฏๅฑ้ฃๆกๅธธๅธธๆฏ `null` ๆ็ณป็ป
|
|
516
|
+
* launcherใๅ ๆญคไธ่ฝๅ็ฌฌไธๆกๅฐฑ break๏ผไผๅ
ๅๅ
ๅซ็ฎๆ ๅ
ๅ็้ฃๆก๏ผ
|
|
517
|
+
* ๅ
ถๆฌกๅไปปๆๅธฆ `pkg/activity` ็ปไปถๅ็้ null ่ก๏ผๆๅๅ
ๅบ็ฌฌไธๆกใ
|
|
518
|
+
*/
|
|
519
|
+
private pickBestLine(lines: string[]): string | undefined {
|
|
520
|
+
if (lines.length === 0) return undefined;
|
|
521
|
+
if (this.targetPackage) {
|
|
522
|
+
const hit = lines.find((l) => l.includes(this.targetPackage!));
|
|
523
|
+
if (hit) return hit;
|
|
524
|
+
}
|
|
525
|
+
const withComponent = lines.find((l) => !/=null\b/.test(l) && /[\w.]+\/[\w.]+/.test(l));
|
|
526
|
+
return withComponent ?? lines[0];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
getCurrentActivity(): Record<string, string> {
|
|
530
|
+
const info: Record<string, string> = {};
|
|
531
|
+
const actOut = this.shell(['dumpsys', 'activity', 'activities']);
|
|
532
|
+
const winOut = this.shell(['dumpsys', 'window']);
|
|
533
|
+
|
|
534
|
+
const resumedLines: string[] = [];
|
|
535
|
+
for (const line of actOut.split(/\r?\n/)) {
|
|
536
|
+
if (/ResumedActivity|topActivity/i.test(line)) resumedLines.push(line.trim());
|
|
537
|
+
}
|
|
538
|
+
const resumed = this.pickBestLine(resumedLines);
|
|
539
|
+
if (resumed !== undefined) info['resumed_activity'] = resumed;
|
|
540
|
+
|
|
541
|
+
const focusLines: string[] = [];
|
|
542
|
+
const focusedAppLines: string[] = [];
|
|
543
|
+
for (const line of winOut.split(/\r?\n/)) {
|
|
544
|
+
if (line.includes('mCurrentFocus')) focusLines.push(line.trim());
|
|
545
|
+
else if (line.includes('mFocusedApp')) focusedAppLines.push(line.trim());
|
|
546
|
+
}
|
|
547
|
+
const focus = this.pickBestLine(focusLines);
|
|
548
|
+
if (focus !== undefined) info['current_focus'] = focus;
|
|
549
|
+
const focusedApp = this.pickBestLine(focusedAppLines);
|
|
550
|
+
if (focusedApp !== undefined) info['focused_app'] = focusedApp;
|
|
551
|
+
|
|
552
|
+
return info;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
tap(x: number, y: number): void {
|
|
556
|
+
this.shell(['input', 'tap', String(x), String(y)]);
|
|
557
|
+
this.waitForIdle();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
longPress(x: number, y: number, durationMs = 1000): void {
|
|
561
|
+
this.shell(['input', 'swipe', String(x), String(y), String(x), String(y), String(durationMs)]);
|
|
562
|
+
this.waitForIdle();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
tapOrLongPress(x: number, y: number, isLongClick = false): void {
|
|
566
|
+
if (isLongClick) this.longPress(x, y);
|
|
567
|
+
else this.tap(x, y);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
pressBack(): void {
|
|
571
|
+
this.shell(['input', 'keyevent', 'KEYCODE_BACK']);
|
|
572
|
+
this.waitForIdle();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
dumpXmlQuick(): string {
|
|
576
|
+
const remotePath = '/sdcard/ui_dump.xml';
|
|
577
|
+
this.shell(['rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml'], 20);
|
|
578
|
+
for (let k = 0; k < 6; k++) {
|
|
579
|
+
const check = this.shell(['ls', remotePath]);
|
|
580
|
+
if (check.includes(remotePath)) break;
|
|
581
|
+
sleep(0.2);
|
|
582
|
+
}
|
|
583
|
+
return this.shell(['cat', remotePath], 15);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
getViewXmlFast(pageDir: string): string {
|
|
587
|
+
const xmlContent = this.dumpXmlQuick();
|
|
588
|
+
if (xmlContent) fs.writeFileSync(path.join(pageDir, 'view.xml'), xmlContent, 'utf-8');
|
|
589
|
+
return xmlContent;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* ่งฃๆ็ฎๆ App ๆๅจๅฑๅน็ SurfaceFlinger **็ฉ็** display id๏ผscreencap -d ๅช่ฎค็ฉ็ id๏ผ
|
|
594
|
+
* ้ป่พ id ๅฆ 0/2/3 ไผ็ดๆฅ "Failed to take screenshot. Status: -2"๏ผใ
|
|
595
|
+
*
|
|
596
|
+
* ๅคๅฑ่ฎพๅค๏ผ่ฝฆๆบ cluster + ไธปๅฑใๆๅ ๅฑ็ญ๏ผไธไธไผ -d ๆถ๏ผscreencap ไผๅ
ๅพ stdout
|
|
597
|
+
* ๆไธๆฎต "[Warning] Multiple displays were found..." ๆๆฌ๏ผๆ PNG ไบ่ฟๅถๆดไฝๅ็งป
|
|
598
|
+
* ๅฏผ่ดๅพ็ๆไธๅผ๏ผไธๅฎ"้ป่ฎคๅ็ฌฌไธไธช display"๏ผไธไฟ่ฏๅฐฑๆฏ App ๆๅจ้ฃๅๅฑใ
|
|
599
|
+
*
|
|
600
|
+
* ๆต็จ๏ผdumpsys activity activities ๆพๅฐ็ฎๆ ๅ
ๆๅจ็ `Display #N`
|
|
601
|
+
* โ dumpsys display ้็จ `displayId N,` ๆๅจ่ก็ uniqueId "local:<physId>" ๅ็ฉ็ idใ
|
|
602
|
+
* ๅๅฑ่ฎพๅค่งฃๆๅคฑ่ดฅๆถ่ฟๅ null๏ผ้ๅไธๅธฆ -d ็่่กไธบ๏ผๆญคๆถไนไธไผๆ่ญฆๅ๏ผใ
|
|
603
|
+
*/
|
|
604
|
+
private resolveTargetPhysicalDisplayId(): string | null {
|
|
605
|
+
if (this.cachedPhysicalDisplayId !== undefined) return this.cachedPhysicalDisplayId;
|
|
606
|
+
let result: string | null = null;
|
|
607
|
+
try {
|
|
608
|
+
if (this.targetPackage) {
|
|
609
|
+
const actOut = this.shell(['dumpsys', 'activity', 'activities']);
|
|
610
|
+
let curDisplay: string | null = null;
|
|
611
|
+
let logicalId: string | null = null;
|
|
612
|
+
for (const line of actOut.split(/\r?\n/)) {
|
|
613
|
+
const m = line.match(/^Display #(\d+)/);
|
|
614
|
+
if (m) curDisplay = m[1];
|
|
615
|
+
else if (curDisplay && line.includes(this.targetPackage)) {
|
|
616
|
+
logicalId = curDisplay;
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (logicalId !== null) {
|
|
621
|
+
const dispOut = this.shell(['dumpsys', 'display']);
|
|
622
|
+
// ้่กๆซๆ๏ผDisplayInfo ๆฏๅ่ก้ฟไธฒ๏ผ`displayId <N>,` ไธ uniqueId ๅ่กใ
|
|
623
|
+
for (const line of dispOut.split(/\r?\n/)) {
|
|
624
|
+
if (!line.includes(`displayId ${logicalId},`)) continue;
|
|
625
|
+
const m = line.match(/uniqueId "local:(\d+)"/);
|
|
626
|
+
if (m) {
|
|
627
|
+
result = m[1];
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
} catch {
|
|
634
|
+
result = null;
|
|
635
|
+
}
|
|
636
|
+
if (result) console.log(` [INFO] ๆชๅพ็ฎๆ display ็ฉ็ id: ${result}`);
|
|
637
|
+
this.cachedPhysicalDisplayId = result;
|
|
638
|
+
return result;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
screencapBytes(): Buffer | null {
|
|
642
|
+
const physId = this.resolveTargetPhysicalDisplayId();
|
|
643
|
+
const capArgs = physId
|
|
644
|
+
? ['exec-out', 'screencap', '-d', physId, '-p']
|
|
645
|
+
: ['exec-out', 'screencap', '-p'];
|
|
646
|
+
const res = spawnSync(this.adbPath, this.buildArgs(capArgs), {
|
|
647
|
+
timeout: 15 * 1000,
|
|
648
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
649
|
+
windowsHide: true,
|
|
650
|
+
}) as SpawnSyncReturns<Buffer>;
|
|
651
|
+
if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
|
652
|
+
console.log(' [WARN] ๆชๅพ่ถ
ๆถ');
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
const out = res.stdout ?? Buffer.alloc(0);
|
|
656
|
+
if (out.length === 0) return null;
|
|
657
|
+
// ๅ
ๅบ๏ผๆ ่ฎบๅฆไฝ้ฝไป PNG magic ๅคๆชๆญ๏ผๅฅๆ screencap ๅฏ่ฝๅ็ฝฎๅฐ stdout ็่ญฆๅๆๆฌใ
|
|
658
|
+
const magic = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
659
|
+
const at = out.indexOf(magic);
|
|
660
|
+
if (at < 0) {
|
|
661
|
+
console.log(` [WARN] ๆชๅพ่พๅบไธๅซ PNG ๆฐๆฎ: ${out.subarray(0, 120).toString('utf-8').trim()}`);
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
return at === 0 ? out : out.subarray(at);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
takeScreenshot(pageDir: string): void {
|
|
668
|
+
const localPath = path.join(pageDir, 'screenshot.png');
|
|
669
|
+
const data = this.screencapBytes();
|
|
670
|
+
if (data) {
|
|
671
|
+
fs.writeFileSync(localPath, data);
|
|
672
|
+
} else {
|
|
673
|
+
const remotePath = '/sdcard/screenshot.png';
|
|
674
|
+
const physId = this.resolveTargetPhysicalDisplayId();
|
|
675
|
+
// ่ฝ็ๅฐ่ฎพๅคๅ pull ๆถๅๆ ท่ฆๆๅฎ display๏ผๅฆๅๅคๅฑไธๅฏ่ฝๆๅฐๅฏๅฑใ
|
|
676
|
+
if (physId) this.shell(['screencap', '-d', physId, '-p', remotePath]);
|
|
677
|
+
else this.shell(['screencap', '-p', remotePath]);
|
|
678
|
+
sleep(0.3);
|
|
679
|
+
this.run(['pull', remotePath, localPath], 15);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
scrollToPosition(containerBounds: [number, number, number, number], scrollIndex: number): void {
|
|
684
|
+
if (scrollIndex <= 0) return;
|
|
685
|
+
const [x1, y1, , y2] = containerBounds;
|
|
686
|
+
const x2 = containerBounds[2];
|
|
687
|
+
const cx = Math.trunc((x1 + x2) / 2);
|
|
688
|
+
const height = y2 - y1;
|
|
689
|
+
const swipeFromY = y1 + Math.trunc(height * 0.7);
|
|
690
|
+
const swipeToY = y1 + Math.trunc(height * 0.3);
|
|
691
|
+
for (let k = 0; k < scrollIndex; k++) {
|
|
692
|
+
this.shell(['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(this.scrollDurationMs)]);
|
|
693
|
+
this.waitForIdle();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
flushPendingPulls(): void {
|
|
698
|
+
// TS ็ๆชๅพไธบๅๆญฅๅๅ
ฅ๏ผๆ ๅผๆญฅ pull ้ๅ๏ผไฟ็็ฉบๅฎ็ฐไปฅๅฏน้ฝ่ฐ็จ็นใ
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
703
|
+
// ้ๅ้กน / ่ฎฐๅฝ็ฑปๅ
|
|
704
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
705
|
+
interface ScrollInfo {
|
|
706
|
+
scroll_index: number;
|
|
707
|
+
container_bounds: number[];
|
|
708
|
+
}
|
|
709
|
+
interface ClickStep {
|
|
710
|
+
from: string;
|
|
711
|
+
element: string;
|
|
712
|
+
center: number[];
|
|
713
|
+
'resource-id': string;
|
|
714
|
+
text: string;
|
|
715
|
+
'content-desc': string;
|
|
716
|
+
'long-clickable': boolean;
|
|
717
|
+
scroll_info: ScrollInfo | null;
|
|
718
|
+
}
|
|
719
|
+
type QueueItem = [ClickStep[], ElemId, string[], ScrollInfo | null];
|
|
720
|
+
|
|
721
|
+
interface PageRecord {
|
|
722
|
+
page_id: string | null;
|
|
723
|
+
label: string;
|
|
724
|
+
timestamp: string;
|
|
725
|
+
activity_info: Record<string, string>;
|
|
726
|
+
clickable_count: number;
|
|
727
|
+
clickable_elements: ClickableElement[];
|
|
728
|
+
scroll_container_bounds: number[] | null;
|
|
729
|
+
_tmp_dir?: string;
|
|
730
|
+
_screenshots?: string[];
|
|
731
|
+
_view_xmls?: string[];
|
|
732
|
+
screenshot?: string;
|
|
733
|
+
view_xml?: string;
|
|
734
|
+
screenshots?: string[];
|
|
735
|
+
view_xmls?: string[];
|
|
736
|
+
click_path?: ClickStep[] | 'manual';
|
|
737
|
+
came_from?: string;
|
|
738
|
+
trigger_element?: string;
|
|
739
|
+
description?: string;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function escapeHtml(s: string): string {
|
|
743
|
+
return s
|
|
744
|
+
.replace(/&/g, '&')
|
|
745
|
+
.replace(/</g, '<')
|
|
746
|
+
.replace(/>/g, '>')
|
|
747
|
+
.replace(/"/g, '"')
|
|
748
|
+
.replace(/'/g, ''');
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
752
|
+
// PageCrawler๏ผUI ้ๅ้ป่พใ้กต้ข้้ใๆญ็น็ปญ่ทใๆฅๅ็ๆ
|
|
753
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
754
|
+
class PageCrawler {
|
|
755
|
+
adb: ADBHelper;
|
|
756
|
+
package: string;
|
|
757
|
+
outputRoot: string;
|
|
758
|
+
maxPages: number;
|
|
759
|
+
maxScrolls: number;
|
|
760
|
+
skipPatterns: string[];
|
|
761
|
+
|
|
762
|
+
// ่ฟ่กๆถ็ถๆ
|
|
763
|
+
pagesIndex: PageRecord[] = [];
|
|
764
|
+
visitedSignatures = new Set<string>();
|
|
765
|
+
pathSignatures: Record<string, string> = {};
|
|
766
|
+
blockedElements = new Set<string>();
|
|
767
|
+
queue: QueueItem[] = [];
|
|
768
|
+
pageCounter = 0;
|
|
769
|
+
stats = { skipped_elements: 0, probe_skipped: 0 };
|
|
770
|
+
|
|
771
|
+
constructor(opts: {
|
|
772
|
+
adb: ADBHelper;
|
|
773
|
+
package: string;
|
|
774
|
+
outputDir: string;
|
|
775
|
+
maxPages?: number;
|
|
776
|
+
maxScrolls?: number;
|
|
777
|
+
skipPatterns?: string[];
|
|
778
|
+
}) {
|
|
779
|
+
this.adb = opts.adb;
|
|
780
|
+
this.package = opts.package;
|
|
781
|
+
this.outputRoot = opts.outputDir;
|
|
782
|
+
this.maxPages = opts.maxPages ?? MAX_PAGES_DEFAULT;
|
|
783
|
+
this.maxScrolls = opts.maxScrolls ?? MAX_SCROLLS_DEFAULT;
|
|
784
|
+
this.skipPatterns = opts.skipPatterns ?? SKIP_RESOURCE_ID_PATTERNS_DEFAULT;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
static elemIdOf(elem: ClickableElement): ElemId {
|
|
788
|
+
return [
|
|
789
|
+
elem['resource-id'] ?? '',
|
|
790
|
+
elem.text ?? '',
|
|
791
|
+
elem['content-desc'] ?? '',
|
|
792
|
+
elem.bounds ?? '',
|
|
793
|
+
elem['long-clickable'] ?? false,
|
|
794
|
+
];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
shouldSkipElement(elem: ClickableElement): boolean {
|
|
798
|
+
const rid = elem['resource-id'] ?? '';
|
|
799
|
+
const text = elem.text ?? '';
|
|
800
|
+
const desc = elem['content-desc'] ?? '';
|
|
801
|
+
const cls = elem.class ?? '';
|
|
802
|
+
for (const pattern of this.skipPatterns) if (rid.includes(pattern)) return true;
|
|
803
|
+
if (!text && !desc && !rid && cls.includes('ImageView')) return true;
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
isInTargetApp(activityInfo: Record<string, string>): boolean {
|
|
808
|
+
const focus = activityInfo['current_focus'] ?? '';
|
|
809
|
+
const resumed = activityInfo['resumed_activity'] ?? '';
|
|
810
|
+
console.log(focus, resumed);
|
|
811
|
+
return focus.includes(this.package) || resumed.includes(this.package);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
scrollAndDump(
|
|
815
|
+
pageDir: string,
|
|
816
|
+
containerBounds: [number, number, number, number],
|
|
817
|
+
initialXml = '',
|
|
818
|
+
): { xmlList: string[]; xmlPaths: string[]; screenshotPaths: string[] } {
|
|
819
|
+
const [x1, y1, , y2b] = containerBounds;
|
|
820
|
+
const x2 = containerBounds[2];
|
|
821
|
+
const y2 = y2b;
|
|
822
|
+
const cx = Math.trunc((x1 + x2) / 2);
|
|
823
|
+
const height = y2 - y1;
|
|
824
|
+
const swipeFromY = y1 + Math.trunc(height * 0.7);
|
|
825
|
+
const swipeToY = y1 + Math.trunc(height * 0.3);
|
|
826
|
+
|
|
827
|
+
const xmlList: string[] = [];
|
|
828
|
+
const xmlPaths: string[] = [];
|
|
829
|
+
const screenshotPaths: string[] = [];
|
|
830
|
+
let prevKeys: Set<string> | null = initialXml ? extractNodeKeys(initialXml) : null;
|
|
831
|
+
|
|
832
|
+
for (let i = 0; i < this.maxScrolls; i++) {
|
|
833
|
+
this.adb.shell(['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(this.adb.scrollDurationMs)]);
|
|
834
|
+
this.adb.waitForIdle();
|
|
835
|
+
const newXml = this.adb.dumpXmlQuick();
|
|
836
|
+
const newKeys = extractNodeKeys(newXml);
|
|
837
|
+
if (prevKeys !== null && setsEqual(newKeys, prevKeys)) {
|
|
838
|
+
console.log(` ๐ ๆปๅจ ${i + 1} ๆฌกๅๅฐ่พพๅบ้จ๏ผ่ทณ่ฟๆชๅพ`);
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
prevKeys = newKeys;
|
|
842
|
+
xmlList.push(newXml);
|
|
843
|
+
const xmlPath = path.join(pageDir, `view_scroll_${i + 1}.xml`);
|
|
844
|
+
fs.writeFileSync(xmlPath, newXml, 'utf-8');
|
|
845
|
+
xmlPaths.push(xmlPath);
|
|
846
|
+
const ssPath = path.join(pageDir, `screenshot_scroll_${i + 1}.png`);
|
|
847
|
+
const data = this.adb.screencapBytes();
|
|
848
|
+
if (data) {
|
|
849
|
+
fs.writeFileSync(ssPath, data);
|
|
850
|
+
screenshotPaths.push(ssPath);
|
|
851
|
+
console.log(` ๐ธ ๆปๅจ ${i + 1}: XML + ๆชๅพๅทฒไฟๅญ`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return { xmlList, xmlPaths, screenshotPaths };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
capturePage(label: string): {
|
|
858
|
+
record: PageRecord;
|
|
859
|
+
xmlContent: string;
|
|
860
|
+
clickable: ClickableElement[];
|
|
861
|
+
scrollContainerBounds: [number, number, number, number] | null;
|
|
862
|
+
} {
|
|
863
|
+
const tmpDir = path.join(this.outputRoot, '_tmp_capture');
|
|
864
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
865
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
866
|
+
|
|
867
|
+
console.log(`\n๐ [้้ไธญ] ${label}`);
|
|
868
|
+
const actInfo = this.adb.getCurrentActivity();
|
|
869
|
+
console.log(` Focus : ${actInfo['current_focus'] ?? 'N/A'}`);
|
|
870
|
+
console.log(` Activity: ${actInfo['resumed_activity'] ?? 'N/A'}`);
|
|
871
|
+
|
|
872
|
+
const xmlContent = this.adb.getViewXmlFast(tmpDir);
|
|
873
|
+
console.log(` View XML ๅทฒไฟๅญ (${Buffer.byteLength(xmlContent, 'utf-8')} bytes)`);
|
|
874
|
+
this.adb.takeScreenshot(tmpDir);
|
|
875
|
+
console.log(' ๆชๅพๅทฒไฟๅญ');
|
|
876
|
+
|
|
877
|
+
const allScreenshots = [path.join(tmpDir, 'screenshot.png')];
|
|
878
|
+
const allViewXmls = [path.join(tmpDir, 'view.xml')];
|
|
879
|
+
const allXmlContents = [xmlContent];
|
|
880
|
+
const scrollContainers = findScrollableContainers(xmlContent);
|
|
881
|
+
if (scrollContainers.length > 0) {
|
|
882
|
+
const container = scrollContainers[0];
|
|
883
|
+
console.log(` ๐ ๆฃๆตๅฐๅฏๆปๅจๅฎนๅจ: ${container.class} ${container['resource-id']}`);
|
|
884
|
+
const { xmlList, xmlPaths, screenshotPaths } = this.scrollAndDump(tmpDir, container.bounds, xmlContent);
|
|
885
|
+
if (xmlList.length > 0) {
|
|
886
|
+
allXmlContents.push(...xmlList);
|
|
887
|
+
allViewXmls.push(...xmlPaths);
|
|
888
|
+
allScreenshots.push(...screenshotPaths);
|
|
889
|
+
console.log(` ๐ ๆปๅจ้้ๅฎๆ๏ผ${xmlList.length} ๆฌกๆปๅจ๏ผๅ
ฑ ${allScreenshots.length} ๅผ ๆชๅพ`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const seenClickable = new Set<string>();
|
|
894
|
+
const clickable: ClickableElement[] = [];
|
|
895
|
+
allXmlContents.forEach((xc, idx) => {
|
|
896
|
+
for (const elem of getClickableElements(xc)) {
|
|
897
|
+
const key = [
|
|
898
|
+
elem['resource-id'] ?? '',
|
|
899
|
+
elem.text ?? '',
|
|
900
|
+
elem['content-desc'] ?? '',
|
|
901
|
+
elem.class ?? '',
|
|
902
|
+
elem.bounds ?? '',
|
|
903
|
+
String(elem['long-clickable'] ?? false),
|
|
904
|
+
].join(' ');
|
|
905
|
+
if (!seenClickable.has(key)) {
|
|
906
|
+
seenClickable.add(key);
|
|
907
|
+
elem.scroll_index = idx;
|
|
908
|
+
clickable.push(elem);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
console.log(` ๅฏ็นๅปๅ
็ด : ${clickable.length} ไธช`);
|
|
913
|
+
|
|
914
|
+
const scrollContainerBounds = scrollContainers.length > 0 ? scrollContainers[0].bounds : null;
|
|
915
|
+
const record: PageRecord = {
|
|
916
|
+
page_id: null,
|
|
917
|
+
label,
|
|
918
|
+
timestamp: new Date().toISOString(),
|
|
919
|
+
activity_info: actInfo,
|
|
920
|
+
clickable_count: clickable.length,
|
|
921
|
+
clickable_elements: clickable,
|
|
922
|
+
scroll_container_bounds: scrollContainerBounds ? [...scrollContainerBounds] : null,
|
|
923
|
+
_tmp_dir: tmpDir,
|
|
924
|
+
_screenshots: allScreenshots,
|
|
925
|
+
_view_xmls: allViewXmls,
|
|
926
|
+
};
|
|
927
|
+
return { record, xmlContent, clickable, scrollContainerBounds };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
restartToRoot(
|
|
931
|
+
launchPackage: string | null,
|
|
932
|
+
launchActivity: string | null,
|
|
933
|
+
rootActivityKeyword: string | null = null,
|
|
934
|
+
rootIdentity: RootIdentity | null = null,
|
|
935
|
+
): boolean {
|
|
936
|
+
if (!launchPackage || !launchActivity) {
|
|
937
|
+
console.log(' [WARN] ๅ
ๅๆ Activity ๅไธบ็ฉบ๏ผๆ ๆณ้ๅฏ');
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
const component = `${launchPackage}/${launchActivity}`;
|
|
941
|
+
|
|
942
|
+
const isAtRoot = (): boolean => {
|
|
943
|
+
const activityInfo = this.adb.getCurrentActivity();
|
|
944
|
+
const focus = activityInfo['current_focus'] ?? '';
|
|
945
|
+
const resumed = activityInfo['resumed_activity'] ?? '';
|
|
946
|
+
if (rootActivityKeyword) {
|
|
947
|
+
if (!focus.includes(rootActivityKeyword) && !resumed.includes(rootActivityKeyword)) return false;
|
|
948
|
+
}
|
|
949
|
+
if (rootIdentity) {
|
|
950
|
+
const xml = this.adb.dumpXmlQuick();
|
|
951
|
+
const curIdentity = rootIdentifiedSet(activityInfo, xml);
|
|
952
|
+
if (!isSameRootPage(rootIdentity, curIdentity)) {
|
|
953
|
+
const [, curElems] = curIdentity;
|
|
954
|
+
const [, rootElems] = rootIdentity;
|
|
955
|
+
let inter = 0;
|
|
956
|
+
for (const v of rootElems) if (curElems.has(v)) inter++;
|
|
957
|
+
const union = rootElems.size + curElems.size - inter;
|
|
958
|
+
const sim = union ? inter / union : 0;
|
|
959
|
+
console.log(` [DEBUG] ๆ น้กต้ข็ธไผผๅบฆ: ${sim.toFixed(2)} (ไบค้${inter}/ๅนถ้${union})`);
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return true;
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
this.adb.shell(['am', 'force-stop', launchPackage]);
|
|
967
|
+
sleep(1.0);
|
|
968
|
+
this.adb.shell(['am', 'start', '-n', component, '--activity-clear-task', '--activity-clear-top']);
|
|
969
|
+
this.adb.waitForIdle();
|
|
970
|
+
|
|
971
|
+
for (let retry = 0; retry < 4; retry++) {
|
|
972
|
+
if (isAtRoot()) return true;
|
|
973
|
+
console.log(` [INFO] ็ญๅพ
้กต้ขๅ ่ฝฝๅฎๆ... (้่ฏ ${retry + 1}/4)`);
|
|
974
|
+
sleep(2);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
console.log(' [WARN] force-stop ๅๆชๅฐ่พพๆ น้กต้ข๏ผApp ๆขๅคไบไธๆฌก็ Tab ็ถๆ๏ผ');
|
|
978
|
+
console.log(' [INFO] ๅฐ่ฏ back + ็นๅป่ทฏๅพๆขๅค...');
|
|
979
|
+
this.adb.pressBack();
|
|
980
|
+
if (isAtRoot()) return true;
|
|
981
|
+
|
|
982
|
+
console.log(' [WARN] ๆๆๆขๅคๆนๅผๅๅคฑ่ดฅ๏ผ่ทณ่ฟๆญคๆฌกๅฏผ่ช');
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
navigateViaRestart(
|
|
987
|
+
clickPath: ClickStep[],
|
|
988
|
+
launchPackage: string | null,
|
|
989
|
+
launchActivity: string | null,
|
|
990
|
+
rootActivityKeyword: string | null = null,
|
|
991
|
+
stepSignatures: string[] | null = null,
|
|
992
|
+
rootIdentity: RootIdentity | null = null,
|
|
993
|
+
): boolean {
|
|
994
|
+
if (!launchPackage || !launchActivity) {
|
|
995
|
+
console.log(' [WARN] ๅ
ๅๆ Activity ๅไธบ็ฉบ๏ผๆ ๆณๅฏผ่ช');
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
if (clickPath.length === 0) return true;
|
|
999
|
+
if (!this.restartToRoot(launchPackage, launchActivity, rootActivityKeyword, rootIdentity)) {
|
|
1000
|
+
console.log(' [ERR] ๆ ๆณๅๅฐๆ น้กต้ข๏ผๅฏผ่ชๅคฑ่ดฅ');
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
for (let i = 0; i < clickPath.length; i++) {
|
|
1005
|
+
const step = clickPath[i];
|
|
1006
|
+
let xml = this.adb.dumpXmlQuick();
|
|
1007
|
+
const stepScroll = step.scroll_info;
|
|
1008
|
+
if (stepScroll && stepScroll.scroll_index > 0) {
|
|
1009
|
+
this.adb.scrollToPosition(stepScroll.container_bounds as [number, number, number, number], stepScroll.scroll_index);
|
|
1010
|
+
xml = this.adb.dumpXmlQuick();
|
|
1011
|
+
}
|
|
1012
|
+
const elemId: [string, string, string, string] = [step['resource-id'] ?? '', step.text ?? '', step['content-desc'] ?? '', ''];
|
|
1013
|
+
const found = findElementOnScreen(xml, elemId);
|
|
1014
|
+
const isLong = step['long-clickable'] ?? false;
|
|
1015
|
+
if (found && found.center) {
|
|
1016
|
+
this.adb.tapOrLongPress(found.center[0], found.center[1], isLong);
|
|
1017
|
+
} else if (step.center) {
|
|
1018
|
+
console.log(` [WARN] ๆชๆพๅฐๅ
็ด ${step.element ?? '?'}๏ผไฝฟ็จ่ฎฐๅฝๅๆ `);
|
|
1019
|
+
this.adb.tapOrLongPress(step.center[0], step.center[1], isLong);
|
|
1020
|
+
} else {
|
|
1021
|
+
console.log(` [ERR] ๆ ๆณๅฏผ่ชๅฐๆญฅ้ชค: ${step.element ?? '?'}`);
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (stepSignatures && i < stepSignatures.length && stepSignatures[i]) {
|
|
1026
|
+
const verifyXml = this.adb.dumpXmlQuick();
|
|
1027
|
+
const verifyInfo = this.adb.getCurrentActivity();
|
|
1028
|
+
const actualSig = pageSignature(verifyInfo, verifyXml);
|
|
1029
|
+
if (actualSig !== stepSignatures[i]) {
|
|
1030
|
+
const expectedAct = stepSignatures[i].split('|')[0];
|
|
1031
|
+
const actualAct = actualSig.split('|')[0];
|
|
1032
|
+
if (expectedAct !== actualAct) {
|
|
1033
|
+
console.log(` [WARN] ๅฏผ่ชๆญฅ้ชค ${i + 1} ๅ Activity ไธๅน้
(ๆๆ=${expectedAct}, ๅฎ้
=${actualAct})`);
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return true;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// โโ ๆญ็น็ปญ่ท โโ
|
|
1043
|
+
private saveQueue(): void {
|
|
1044
|
+
const items = this.queue.map(([clickPath, elemId, stepSigs, scrollInfo]) => [clickPath, [...elemId], stepSigs, scrollInfo]);
|
|
1045
|
+
try {
|
|
1046
|
+
fs.writeFileSync(path.join(this.outputRoot, 'queue_checkpoint.json'), JSON.stringify(items), 'utf-8');
|
|
1047
|
+
} catch (e) {
|
|
1048
|
+
console.log(` [WARN] ไฟๅญ้ๅๆฃๆฅ็นๅคฑ่ดฅ: ${(e as Error).message}`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private loadQueue(): QueueItem[] | null {
|
|
1053
|
+
const qfile = path.join(this.outputRoot, 'queue_checkpoint.json');
|
|
1054
|
+
if (!fs.existsSync(qfile)) return null;
|
|
1055
|
+
try {
|
|
1056
|
+
const items = JSON.parse(fs.readFileSync(qfile, 'utf-8')) as [ClickStep[], unknown[], string[], ScrollInfo | null][];
|
|
1057
|
+
return items.map(([clickPath, elemIdList, stepSigs, scrollInfo]) => [
|
|
1058
|
+
clickPath,
|
|
1059
|
+
elemIdList as ElemId,
|
|
1060
|
+
stepSigs,
|
|
1061
|
+
scrollInfo,
|
|
1062
|
+
]);
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
console.log(` [WARN] ๅ ่ฝฝ้ๅๆฃๆฅ็นๅคฑ่ดฅ: ${(e as Error).message}๏ผๅฐ้ๅปบ้ๅ`);
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private rebuildQueueFromPages(pagesIndex: PageRecord[]): QueueItem[] {
|
|
1070
|
+
const queue: QueueItem[] = [];
|
|
1071
|
+
for (const page of pagesIndex) {
|
|
1072
|
+
const clickPath = (Array.isArray(page.click_path) ? page.click_path : []) as ClickStep[];
|
|
1073
|
+
const clickable = page.clickable_elements ?? [];
|
|
1074
|
+
const scrollContainerBounds = page.scroll_container_bounds;
|
|
1075
|
+
for (const elem of clickable) {
|
|
1076
|
+
if (this.shouldSkipElement(elem)) continue;
|
|
1077
|
+
const elemId = PageCrawler.elemIdOf(elem);
|
|
1078
|
+
const si = elem.scroll_index ?? 0;
|
|
1079
|
+
let scrollInfo: ScrollInfo | null = null;
|
|
1080
|
+
if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] };
|
|
1081
|
+
queue.push([clickPath, elemId, [], scrollInfo]);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return queue;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
private tryLoadCheckpoint():
|
|
1088
|
+
| { pagesIndex: PageRecord[]; visited: Set<string>; pageCounter: number; queue: QueueItem[]; blocked: Set<string> }
|
|
1089
|
+
| null {
|
|
1090
|
+
const indexFile = path.join(this.outputRoot, 'index.json');
|
|
1091
|
+
if (!fs.existsSync(indexFile)) return null;
|
|
1092
|
+
const pageDirs = fs.existsSync(this.outputRoot)
|
|
1093
|
+
? fs.readdirSync(this.outputRoot).filter((d) => d.startsWith('page_'))
|
|
1094
|
+
: [];
|
|
1095
|
+
if (pageDirs.length === 0) return null;
|
|
1096
|
+
|
|
1097
|
+
let pagesIndex: PageRecord[];
|
|
1098
|
+
try {
|
|
1099
|
+
pagesIndex = JSON.parse(fs.readFileSync(indexFile, 'utf-8')) as PageRecord[];
|
|
1100
|
+
} catch (e) {
|
|
1101
|
+
console.log(` [WARN] ่ฏปๅ index.json ๅคฑ่ดฅ: ${(e as Error).message}๏ผ้ๆฐๅผๅง`);
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
if (!pagesIndex || pagesIndex.length === 0) return null;
|
|
1105
|
+
|
|
1106
|
+
console.log(`\nโฉ ๅ็ฐๅทฒๆๆฃๆฅ็น๏ผ${pagesIndex.length} ไธช้กต้ข๏ผๅฐ่ฏๆญ็น็ปญ่ท...`);
|
|
1107
|
+
const visited = new Set<string>();
|
|
1108
|
+
let pageCounter = 0;
|
|
1109
|
+
for (const page of pagesIndex) {
|
|
1110
|
+
const pageId = page.page_id ?? '';
|
|
1111
|
+
if (!pageId) continue;
|
|
1112
|
+
const m = pageId.match(/page_(\d+)/);
|
|
1113
|
+
if (m) pageCounter = Math.max(pageCounter, parseInt(m[1], 10));
|
|
1114
|
+
const viewXmlPath = path.join(this.outputRoot, pageId, 'view.xml');
|
|
1115
|
+
if (fs.existsSync(viewXmlPath)) {
|
|
1116
|
+
const xmlContent = fs.readFileSync(viewXmlPath, 'utf-8');
|
|
1117
|
+
visited.add(pageSignature(page.activity_info ?? {}, xmlContent));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const savedQueue = this.loadQueue();
|
|
1122
|
+
let queue: QueueItem[];
|
|
1123
|
+
if (savedQueue !== null) {
|
|
1124
|
+
queue = savedQueue;
|
|
1125
|
+
console.log(` ้ๅๅทฒไปๆฃๆฅ็นๆขๅค๏ผ${queue.length} ไธชๅพ
ๅค็ๅ
็ด ๏ผ็ฒพ็กฎ็ปญ่ท๏ผ`);
|
|
1126
|
+
} else {
|
|
1127
|
+
queue = this.rebuildQueueFromPages(pagesIndex);
|
|
1128
|
+
console.log(` ้ๅๅทฒ้ๅปบ๏ผfallback๏ผ๏ผ${queue.length} ไธชๅ
็ด ๏ผๅซๅทฒๆข็ดข้กน๏ผBFS ไผ่ชๅจ่ทณ่ฟ๏ผ`);
|
|
1129
|
+
}
|
|
1130
|
+
const blocked = new Set<string>();
|
|
1131
|
+
console.log(` ๅทฒๆขๅค ${visited.size} ไธชๅทฒ่ฎฟ้ฎ็ญพๅ๏ผ้ๅ ${queue.length} ไธชๅพ
ๅค็ๅ
็ด `);
|
|
1132
|
+
return { pagesIndex, visited, pageCounter, queue, blocked };
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// โโ ้กต้ขๆไบค โโ
|
|
1136
|
+
private finalizePage(record: PageRecord, pageId: string): void {
|
|
1137
|
+
const pageDir = path.join(this.outputRoot, pageId);
|
|
1138
|
+
const tmpDir = record._tmp_dir!;
|
|
1139
|
+
if (fs.existsSync(tmpDir)) fs.renameSync(tmpDir, pageDir);
|
|
1140
|
+
else fs.mkdirSync(pageDir, { recursive: true });
|
|
1141
|
+
|
|
1142
|
+
record.page_id = pageId;
|
|
1143
|
+
record.screenshot = `${pageId}/screenshot.png`;
|
|
1144
|
+
record.view_xml = `${pageId}/view.xml`;
|
|
1145
|
+
|
|
1146
|
+
const screenshotsList = [`${pageId}/screenshot.png`];
|
|
1147
|
+
for (const ssPath of record._screenshots ?? []) {
|
|
1148
|
+
const ssName = path.basename(ssPath);
|
|
1149
|
+
if (ssName !== 'screenshot.png') screenshotsList.push(`${pageId}/${ssName}`);
|
|
1150
|
+
}
|
|
1151
|
+
record.screenshots = screenshotsList;
|
|
1152
|
+
|
|
1153
|
+
const viewXmlsList = [`${pageId}/view.xml`];
|
|
1154
|
+
for (const xmlPath of record._view_xmls ?? []) {
|
|
1155
|
+
const xmlName = path.basename(xmlPath);
|
|
1156
|
+
if (xmlName !== 'view.xml') viewXmlsList.push(`${pageId}/${xmlName}`);
|
|
1157
|
+
}
|
|
1158
|
+
record.view_xmls = viewXmlsList;
|
|
1159
|
+
|
|
1160
|
+
delete record._tmp_dir;
|
|
1161
|
+
delete record._screenshots;
|
|
1162
|
+
delete record._view_xmls;
|
|
1163
|
+
|
|
1164
|
+
fs.writeFileSync(path.join(pageDir, 'meta.json'), JSON.stringify(record, null, 2), 'utf-8');
|
|
1165
|
+
console.log(` โ
ๅทฒไฟๅญไธบ [${pageId}]`);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private activityNameSuffix(record: PageRecord): string {
|
|
1169
|
+
const resumed = record.activity_info?.['resumed_activity'] ?? '';
|
|
1170
|
+
let mAct = resumed.match(/[\w.]+\/([\w.]+)/);
|
|
1171
|
+
if (!mAct) {
|
|
1172
|
+
const f = record.activity_info?.['current_focus'] ?? '';
|
|
1173
|
+
mAct = f.match(/[\w.]+\/([\w.]+)/);
|
|
1174
|
+
}
|
|
1175
|
+
let activityName = '';
|
|
1176
|
+
if (mAct) {
|
|
1177
|
+
const parts = mAct[1].split('.');
|
|
1178
|
+
activityName = parts[parts.length - 1];
|
|
1179
|
+
}
|
|
1180
|
+
return activityName ? `_${activityName}` : '';
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
commitPage(record: PageRecord): string {
|
|
1184
|
+
this.pageCounter += 1;
|
|
1185
|
+
const suffix = this.activityNameSuffix(record);
|
|
1186
|
+
const pageId = `page_${String(this.pageCounter).padStart(4, '0')}${suffix}`;
|
|
1187
|
+
this.finalizePage(record, pageId);
|
|
1188
|
+
return pageId;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
commitManualPage(record: PageRecord, pageCounter: number): string {
|
|
1192
|
+
const suffix = this.activityNameSuffix(record);
|
|
1193
|
+
const pageId = `manual_${String(pageCounter).padStart(4, '0')}${suffix}`;
|
|
1194
|
+
this.finalizePage(record, pageId);
|
|
1195
|
+
return pageId;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// โโ HTML ๆฅๅ็ๆ โโ
|
|
1199
|
+
generateHtmlReport(pages: PageRecord[]): void {
|
|
1200
|
+
let cards = '';
|
|
1201
|
+
for (const p of pages) {
|
|
1202
|
+
const activity = escapeHtml(p.activity_info?.['current_focus'] ?? 'N/A');
|
|
1203
|
+
const resumed = escapeHtml(p.activity_info?.['resumed_activity'] ?? 'N/A');
|
|
1204
|
+
const clickPath = p.click_path ?? [];
|
|
1205
|
+
let pathDisplay: string;
|
|
1206
|
+
if (clickPath === 'manual') {
|
|
1207
|
+
pathDisplay = `๏ผๆๅจ้้๏ผ${escapeHtml(p.description ?? 'ๆๅจ้้')}๏ผ`;
|
|
1208
|
+
} else if (Array.isArray(clickPath) && clickPath.length > 0) {
|
|
1209
|
+
const longClickTag = '<b class="long-click">้ฟๆ</b>';
|
|
1210
|
+
pathDisplay = clickPath
|
|
1211
|
+
.map(
|
|
1212
|
+
(s) =>
|
|
1213
|
+
`<code>${escapeHtml(s.from)}</code> ${s['long-clickable'] ? longClickTag : '็นๅป'} <b>${escapeHtml(s.element)}</b>`,
|
|
1214
|
+
)
|
|
1215
|
+
.join(' โ ');
|
|
1216
|
+
} else {
|
|
1217
|
+
pathDisplay = '๏ผๅๅง้กต๏ผ';
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const screenshotPath = escapeHtml(p.screenshot ?? '');
|
|
1221
|
+
const screenshots = p.screenshots ?? [p.screenshot ?? ''];
|
|
1222
|
+
const viewXmls = p.view_xmls ?? [p.view_xml ?? ''];
|
|
1223
|
+
let screenshotsHtml: string;
|
|
1224
|
+
if (screenshots.length > 1) {
|
|
1225
|
+
let pairsHtml = '';
|
|
1226
|
+
screenshots.forEach((ss, idx) => {
|
|
1227
|
+
const xmlLink = idx < viewXmls.length ? viewXmls[idx] : viewXmls[viewXmls.length - 1];
|
|
1228
|
+
const labelTag = idx === 0 ? 'ๅๅง' : `ๆปๅจ${idx}`;
|
|
1229
|
+
pairsHtml +=
|
|
1230
|
+
`<div class="scroll-pair">` +
|
|
1231
|
+
`<img src="${escapeHtml(ss)}" alt="screenshot" onerror="this.style.display='none'"/>` +
|
|
1232
|
+
`<a href="${escapeHtml(xmlLink)}" target="_blank">${labelTag} XML</a>` +
|
|
1233
|
+
`</div>`;
|
|
1234
|
+
});
|
|
1235
|
+
screenshotsHtml = `<div class="screenshots-gallery">${pairsHtml}</div>`;
|
|
1236
|
+
} else {
|
|
1237
|
+
screenshotsHtml = `<img src="${escapeHtml(screenshots[0])}" alt="screenshot" onerror="this.style.display='none'"/>`;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const labelEscaped = escapeHtml(p.label);
|
|
1241
|
+
const pageIdEscaped = escapeHtml(p.page_id ?? '');
|
|
1242
|
+
const viewXmlEscaped = escapeHtml(p.view_xml ?? '');
|
|
1243
|
+
let xmlLinksHtml: string;
|
|
1244
|
+
if (viewXmls.length > 1) {
|
|
1245
|
+
xmlLinksHtml = viewXmls
|
|
1246
|
+
.map((vx, i) => `<a href="${escapeHtml(vx)}" target="_blank">๐ XML${i === 0 ? '' : `_${i}`}</a>`)
|
|
1247
|
+
.join(' ');
|
|
1248
|
+
} else {
|
|
1249
|
+
xmlLinksHtml = `<a href="${viewXmlEscaped}" target="_blank">๐ ๆฅ็ View XML</a>`;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
cards += `
|
|
1253
|
+
<div class="card">
|
|
1254
|
+
<div class="card-header">
|
|
1255
|
+
<span class="pid">${pageIdEscaped}</span>
|
|
1256
|
+
<span class="label">${labelEscaped}</span>
|
|
1257
|
+
<span class="time">${escapeHtml((p.timestamp ?? '').slice(0, 19))}</span>
|
|
1258
|
+
</div>
|
|
1259
|
+
<div class="card-body">
|
|
1260
|
+
${screenshotsHtml}
|
|
1261
|
+
<div class="meta">
|
|
1262
|
+
<div><b>Focus:</b> <code>${activity}</code></div>
|
|
1263
|
+
<div><b>Activity:</b> <code>${resumed}</code></div>
|
|
1264
|
+
<div><b>็นๅป่ทฏๅพ:</b> ${pathDisplay}</div>
|
|
1265
|
+
<div><b>ๅฏ็นๅปๅ
็ด :</b> ${p.clickable_count} ไธช</div>
|
|
1266
|
+
<div class="links">
|
|
1267
|
+
${xmlLinksHtml}
|
|
1268
|
+
<a href="${pageIdEscaped}/meta.json" target="_blank">๐ ๅ
ๆฐๆฎ JSON</a>
|
|
1269
|
+
</div>
|
|
1270
|
+
</div>
|
|
1271
|
+
</div>
|
|
1272
|
+
</div>`;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const now = new Date();
|
|
1276
|
+
const pad = (x: number): string => String(x).padStart(2, '0');
|
|
1277
|
+
const genTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
1278
|
+
|
|
1279
|
+
const html = `<!DOCTYPE html>
|
|
1280
|
+
<html lang="zh">
|
|
1281
|
+
<head>
|
|
1282
|
+
<meta charset="UTF-8">
|
|
1283
|
+
<title>Android ้กต้ข้ๅๆฅๅ๏ผๅ ้็๏ผ</title>
|
|
1284
|
+
<style>
|
|
1285
|
+
body { font-family: -apple-system, sans-serif; background: #f5f5f5; margin: 0; padding: 20px; }
|
|
1286
|
+
h1 { color: #333; }
|
|
1287
|
+
.summary { background: #fff; border-radius: 8px; padding: 16px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
1288
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 16px; }
|
|
1289
|
+
.card { background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
1290
|
+
.card-header { background: #1a73e8; color: #fff; padding: 10px 14px; display: flex; gap: 10px; align-items: center; }
|
|
1291
|
+
.pid { font-weight: bold; font-size: 13px; background: rgba(255,255,255,.2); padding: 2px 8px; border-radius: 4px; }
|
|
1292
|
+
.label { flex: 1; font-size: 14px; }
|
|
1293
|
+
.time { font-size: 11px; opacity: .8; }
|
|
1294
|
+
.card-body { display: flex; gap: 12px; padding: 12px; }
|
|
1295
|
+
.card-body img { width: 140px; height: auto; border-radius: 4px; border: 1px solid #eee; object-fit: cover; flex-shrink: 0; }
|
|
1296
|
+
.screenshots-gallery { display: flex; gap: 6px; overflow-x: auto; flex-shrink: 0; max-width: 320px; padding-bottom: 4px; }
|
|
1297
|
+
.screenshots-gallery img { width: 120px; height: auto; border-radius: 4px; border: 1px solid #eee; object-fit: cover; flex-shrink: 0; }
|
|
1298
|
+
.scroll-pair { display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0; }
|
|
1299
|
+
.scroll-pair a { font-size: 10px; color: #1a73e8; text-decoration: none; }
|
|
1300
|
+
.scroll-pair a:hover { text-decoration: underline; }
|
|
1301
|
+
.meta { font-size: 12px; color: #444; display: flex; flex-direction: column; gap: 6px; overflow: hidden; }
|
|
1302
|
+
.meta code { font-size: 11px; background: #f0f0f0; padding: 1px 4px; border-radius: 3px; word-break: break-all; }
|
|
1303
|
+
.links { display: flex; gap: 10px; margin-top: 4px; }
|
|
1304
|
+
.links a { color: #1a73e8; text-decoration: none; font-size: 12px; }
|
|
1305
|
+
.links a:hover { text-decoration: underline; }
|
|
1306
|
+
.long-click { color: #e65100; background: #fff3e0; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
|
|
1307
|
+
</style>
|
|
1308
|
+
</head>
|
|
1309
|
+
<body>
|
|
1310
|
+
<h1>๐ฑ Android ้กต้ข้ๅๆฅๅ๏ผๅ ้็๏ผ</h1>
|
|
1311
|
+
<div class="summary">
|
|
1312
|
+
<b>็ๆๆถ้ด๏ผ</b>${genTime} |
|
|
1313
|
+
<b>ๅ
ฑ้้้กต้ข๏ผ</b>${pages.length} ไธช
|
|
1314
|
+
</div>
|
|
1315
|
+
<div class="grid">
|
|
1316
|
+
${cards}
|
|
1317
|
+
</div>
|
|
1318
|
+
</body>
|
|
1319
|
+
</html>`;
|
|
1320
|
+
|
|
1321
|
+
fs.writeFileSync(path.join(this.outputRoot, 'report.html'), html, 'utf-8');
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
private flushReport(): void {
|
|
1325
|
+
try {
|
|
1326
|
+
fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(this.pagesIndex, null, 2), 'utf-8');
|
|
1327
|
+
this.generateHtmlReport(this.pagesIndex);
|
|
1328
|
+
this.saveQueue();
|
|
1329
|
+
} catch (e) {
|
|
1330
|
+
console.log(` [WARN] ๅๅ
ฅๆฅๅๅคฑ่ดฅ: ${(e as Error).message}`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
crawl(): void {
|
|
1335
|
+
if (!this.adb.checkDevice()) return;
|
|
1336
|
+
|
|
1337
|
+
let activityInfo = this.adb.getCurrentActivity();
|
|
1338
|
+
// ้่ฆๅนฒๅ้ๅฏ็ไธค็งๆ
ๅต๏ผ
|
|
1339
|
+
// 1) ไธๅจ็ฎๆ App ๅ
๏ผๅ้ป่พ๏ผใ
|
|
1340
|
+
// 2) ๅจ็ฎๆ App ๅ
๏ผไฝๅๅฐๆฏๅผน็ช/overlay โโ current_focus ๆ activity ๅ้๏ผๆๅไธๅฐๅฏๅจ็ปไปถใ
|
|
1341
|
+
// ๅธธ่งไบไธไธ่ฝฎ้ๅ/็ปญ่ท็ปๆๆถๅๅจๅผน็ชไธ๏ผresumed_activity ไปๅซๅ
ๅ๏ผไผ่ขซๅคๅฎ"ๅจ App ๅ
"๏ผใ
|
|
1342
|
+
// ๆญคๆถ่ฅไธ้ๅฏ๏ผๅ็ปญๆๆ force-stop ๅฏผ่ช้ฝไผๅ ็ผบๅฐๅฏๅจไฟกๆฏ่ไธญๆญขใ
|
|
1343
|
+
const focusHasComponent = focusHasActivityComponent(activityInfo);
|
|
1344
|
+
const notInApp = !this.isInTargetApp(activityInfo);
|
|
1345
|
+
if (notInApp || !focusHasComponent) {
|
|
1346
|
+
if (notInApp) {
|
|
1347
|
+
console.log(`ๅฝๅไธๅจ็ฎๆ App (${this.package}) ๅ
๏ผๆธ
็ฉบๆฐๆฎๅนถๅฏๅจ...`);
|
|
1348
|
+
} else {
|
|
1349
|
+
console.log(
|
|
1350
|
+
`ๅฝๅๅๅฐไธบๅผน็ช/overlay๏ผ${activityInfo['current_focus'] ?? 'N/A'}๏ผ๏ผforce-stop ๅ้ๆฐๅฏๅจไปฅๅๅฐๅนฒๅๆ น้กต้ข...`,
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
this.adb.shell(['am', 'force-stop', this.package]);
|
|
1354
|
+
sleep(0.5);
|
|
1355
|
+
this.adb.shell(['monkey', '-p', this.package, '-c', 'android.intent.category.LAUNCHER', '1']);
|
|
1356
|
+
this.adb.waitForIdle();
|
|
1357
|
+
activityInfo = this.adb.getCurrentActivity();
|
|
1358
|
+
if (!this.isInTargetApp(activityInfo)) {
|
|
1359
|
+
console.log(`[ERR] ๅฏๅจๅไปๆช่ฟๅ
ฅ็ฎๆ App (${this.package})๏ผ่ฏทๆฃๆฅๅ
ๅๆฏๅฆๆญฃ็กฎ`);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
let launchPackage: string | null = null;
|
|
1365
|
+
let launchActivity: string | null = null;
|
|
1366
|
+
const comp = extractLaunchComponent(activityInfo);
|
|
1367
|
+
if (comp) {
|
|
1368
|
+
[launchPackage, launchActivity] = comp;
|
|
1369
|
+
console.log(`ๅฏๅจ Activity: ${launchPackage}/${launchActivity}`);
|
|
1370
|
+
} else {
|
|
1371
|
+
console.log(
|
|
1372
|
+
`[WARN] ๆ ๆณๆๅๅฏๅจ Activity: focus=${activityInfo['current_focus'] ?? ''} resumed=${activityInfo['resumed_activity'] ?? ''}`,
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
let rootActivityKeyword: string | null = null;
|
|
1377
|
+
if (launchActivity) {
|
|
1378
|
+
const parts = launchActivity.split('.');
|
|
1379
|
+
rootActivityKeyword = parts[parts.length - 1];
|
|
1380
|
+
console.log(`ๆ น Activity ๅ
ณ้ฎๅญ: ${rootActivityKeyword}`);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
fs.mkdirSync(this.outputRoot, { recursive: true });
|
|
1384
|
+
console.log(`\n๐ ่พๅบ็ฎๅฝ: ${path.resolve(this.outputRoot)}\n`);
|
|
1385
|
+
console.log('='.repeat(60));
|
|
1386
|
+
console.log('ๅผๅง้ๅ๏ผๅ ้็๏ผBFS + ่ฝป้ๆขๆต๏ผ...');
|
|
1387
|
+
console.log('='.repeat(60));
|
|
1388
|
+
|
|
1389
|
+
let rootIdentity: RootIdentity | null = null;
|
|
1390
|
+
const checkpoint = this.tryLoadCheckpoint();
|
|
1391
|
+
if (checkpoint) {
|
|
1392
|
+
this.pagesIndex = checkpoint.pagesIndex;
|
|
1393
|
+
this.visitedSignatures = checkpoint.visited;
|
|
1394
|
+
this.pageCounter = checkpoint.pageCounter;
|
|
1395
|
+
this.queue = checkpoint.queue;
|
|
1396
|
+
this.blockedElements = checkpoint.blocked;
|
|
1397
|
+
console.log(`โฉ ๆญ็น็ปญ่ท๏ผไป็ฌฌ ${this.pageCounter + 1} ไธช้กต้ข็ปง็ปญ\n`);
|
|
1398
|
+
for (const p of this.pagesIndex) {
|
|
1399
|
+
if (!p.click_path || (Array.isArray(p.click_path) && p.click_path.length === 0)) {
|
|
1400
|
+
const rootXmlPath = path.join(this.outputRoot, p.page_id!, 'view.xml');
|
|
1401
|
+
if (fs.existsSync(rootXmlPath)) {
|
|
1402
|
+
const rootXml = fs.readFileSync(rootXmlPath, 'utf-8');
|
|
1403
|
+
rootIdentity = rootIdentifiedSet(p.activity_info ?? {}, rootXml);
|
|
1404
|
+
}
|
|
1405
|
+
break;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
} else {
|
|
1409
|
+
const { record, xmlContent, clickable, scrollContainerBounds } = this.capturePage('ๅๅง้กต้ข');
|
|
1410
|
+
const pageId = this.commitPage(record);
|
|
1411
|
+
record.click_path = [];
|
|
1412
|
+
fs.writeFileSync(path.join(this.outputRoot, pageId, 'meta.json'), JSON.stringify(record, null, 2), 'utf-8');
|
|
1413
|
+
const sig = pageSignature(record.activity_info, xmlContent);
|
|
1414
|
+
rootIdentity = rootIdentifiedSet(record.activity_info, xmlContent);
|
|
1415
|
+
this.visitedSignatures.add(sig);
|
|
1416
|
+
this.pathSignatures[JSON.stringify([])] = sig;
|
|
1417
|
+
this.pagesIndex.push(record);
|
|
1418
|
+
this.flushReport();
|
|
1419
|
+
|
|
1420
|
+
for (const elem of clickable) {
|
|
1421
|
+
if (this.shouldSkipElement(elem)) {
|
|
1422
|
+
this.stats.skipped_elements += 1;
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
const elemId = PageCrawler.elemIdOf(elem);
|
|
1426
|
+
const si = elem.scroll_index ?? 0;
|
|
1427
|
+
let scrollInfo: ScrollInfo | null = null;
|
|
1428
|
+
if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] };
|
|
1429
|
+
this.queue.push([[], elemId, [], scrollInfo]);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const failedPaths = new Set<string>();
|
|
1434
|
+
while (this.queue.length > 0 && this.pageCounter < this.maxPages) {
|
|
1435
|
+
const [clickPath, elemId, stepSignatures, scrollInfo] = this.queue.shift()!;
|
|
1436
|
+
const elemKey = JSON.stringify(elemId);
|
|
1437
|
+
if (this.blockedElements.has(elemKey)) continue;
|
|
1438
|
+
|
|
1439
|
+
const pathKey = clickPath.length > 0 ? JSON.stringify(clickPath) : '';
|
|
1440
|
+
let skip = false;
|
|
1441
|
+
for (const fp of failedPaths) {
|
|
1442
|
+
if (pathKey.startsWith(fp) && fp.length > 2) {
|
|
1443
|
+
skip = true;
|
|
1444
|
+
break;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
if (skip) continue;
|
|
1448
|
+
|
|
1449
|
+
const pathDesc = clickPath.length > 0 ? clickPath.map((s) => s.element ?? '?').join(' โ ') : 'ๆ น้กต้ข';
|
|
1450
|
+
console.log(`\n๐งญ ๅฏผ่ชๅฐ: ${pathDesc}`);
|
|
1451
|
+
|
|
1452
|
+
if (!launchPackage || !launchActivity) {
|
|
1453
|
+
console.log(' [ERR] ๆ ๆณ้ๅฏ App๏ผ็ผบๅฐๅฏๅจไฟกๆฏ');
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
if (clickPath.length > 0) {
|
|
1458
|
+
if (!this.navigateViaRestart(clickPath, launchPackage, launchActivity, rootActivityKeyword, stepSignatures, rootIdentity)) {
|
|
1459
|
+
console.log(' [ERR] ๅฏผ่ชๅคฑ่ดฅ๏ผ่ทณ่ฟ');
|
|
1460
|
+
failedPaths.add(pathKey);
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
} else {
|
|
1464
|
+
if (!this.restartToRoot(launchPackage, launchActivity, rootActivityKeyword, rootIdentity)) {
|
|
1465
|
+
console.log(' [ERR] ๆ ๆณๅๅฐๆ น้กต้ข๏ผ่ทณ่ฟ');
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
let freshXml = this.adb.dumpXmlQuick();
|
|
1471
|
+
if (scrollInfo && scrollInfo.scroll_index > 0) {
|
|
1472
|
+
console.log(` ๐ ๆปๅจ ${scrollInfo.scroll_index} ๆฌกๅฐ่พพๅ
็ด ไฝ็ฝฎ`);
|
|
1473
|
+
this.adb.scrollToPosition(scrollInfo.container_bounds as [number, number, number, number], scrollInfo.scroll_index);
|
|
1474
|
+
freshXml = this.adb.dumpXmlQuick();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const foundElem = findElementOnScreen(freshXml, elemId);
|
|
1478
|
+
if (!foundElem || !foundElem.center) {
|
|
1479
|
+
console.log(` [SKIP] ๆ ๆณๆพๅฐๅ
็ด : ${JSON.stringify(elemId.slice(0, 3))}`);
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const center = foundElem.center;
|
|
1484
|
+
const labelText = foundElem.text || foundElem['content-desc'] || foundElem['resource-id'] || 'unknown';
|
|
1485
|
+
console.log(`\n๐ ็นๅป: [${labelText}] @ ${JSON.stringify(center)}`);
|
|
1486
|
+
const isLong = foundElem['long-clickable'] ?? false;
|
|
1487
|
+
if (isLong) console.log(' ๐ ไฝฟ็จ้ฟๆ่งฆๅ');
|
|
1488
|
+
this.adb.tapOrLongPress(center[0], center[1], isLong);
|
|
1489
|
+
|
|
1490
|
+
const probeXml = this.adb.dumpXmlQuick();
|
|
1491
|
+
const probeInfo = this.adb.getCurrentActivity();
|
|
1492
|
+
let newSig = pageSignature(probeInfo, probeXml);
|
|
1493
|
+
|
|
1494
|
+
if (!this.isInTargetApp(probeInfo)) {
|
|
1495
|
+
console.log(' โ ๏ธ ่ทณ่ฝฌๅฐๅ
ถไป App๏ผๆ ่ฎฐๅนถ่ทณ่ฟ');
|
|
1496
|
+
this.blockedElements.add(elemKey);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
if (this.visitedSignatures.has(newSig)) {
|
|
1500
|
+
console.log(' โป๏ธ ้กต้ขๅทฒ่ฎฟ้ฎ่ฟ๏ผ่ทณ่ฟ๏ผ็ๆชๅพ๏ผ');
|
|
1501
|
+
this.stats.probe_skipped += 1;
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
const { record: newRecord, xmlContent: newXml, clickable: newClickable, scrollContainerBounds: newScrollBounds } =
|
|
1506
|
+
this.capturePage(`็นๅป'${labelText}'ๅ`);
|
|
1507
|
+
newSig = pageSignature(newRecord.activity_info, newXml);
|
|
1508
|
+
this.visitedSignatures.add(newSig);
|
|
1509
|
+
const pageId = this.commitPage(newRecord);
|
|
1510
|
+
|
|
1511
|
+
const newClickPath: ClickStep[] = [
|
|
1512
|
+
...clickPath,
|
|
1513
|
+
{
|
|
1514
|
+
from: pathDesc,
|
|
1515
|
+
element: labelText,
|
|
1516
|
+
center: [...center],
|
|
1517
|
+
'resource-id': foundElem['resource-id'] ?? '',
|
|
1518
|
+
text: foundElem.text ?? '',
|
|
1519
|
+
'content-desc': foundElem['content-desc'] ?? '',
|
|
1520
|
+
'long-clickable': isLong,
|
|
1521
|
+
scroll_info: scrollInfo,
|
|
1522
|
+
},
|
|
1523
|
+
];
|
|
1524
|
+
newRecord.came_from = pathDesc;
|
|
1525
|
+
newRecord.trigger_element = labelText;
|
|
1526
|
+
newRecord.click_path = newClickPath;
|
|
1527
|
+
fs.writeFileSync(path.join(this.outputRoot, pageId, 'meta.json'), JSON.stringify(newRecord, null, 2), 'utf-8');
|
|
1528
|
+
this.pagesIndex.push(newRecord);
|
|
1529
|
+
this.flushReport();
|
|
1530
|
+
|
|
1531
|
+
this.pathSignatures[JSON.stringify(newClickPath)] = newSig;
|
|
1532
|
+
const newStepSigs = [...stepSignatures, newSig];
|
|
1533
|
+
|
|
1534
|
+
for (const ne of newClickable) {
|
|
1535
|
+
if (this.shouldSkipElement(ne)) {
|
|
1536
|
+
this.stats.skipped_elements += 1;
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
const newEid = PageCrawler.elemIdOf(ne);
|
|
1540
|
+
const si = ne.scroll_index ?? 0;
|
|
1541
|
+
let neScrollInfo: ScrollInfo | null = null;
|
|
1542
|
+
if (si > 0 && newScrollBounds) neScrollInfo = { scroll_index: si, container_bounds: [...newScrollBounds] };
|
|
1543
|
+
this.queue.push([newClickPath, newEid, newStepSigs, neScrollInfo]);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
this.adb.flushPendingPulls();
|
|
1548
|
+
this.flushReport();
|
|
1549
|
+
try {
|
|
1550
|
+
fs.rmSync(path.join(this.outputRoot, 'queue_checkpoint.json'), { force: true });
|
|
1551
|
+
} catch {
|
|
1552
|
+
/* ignore */
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
console.log('\n' + '='.repeat(60));
|
|
1556
|
+
console.log(`โ
้ๅๅฎๆ๏ผๅ
ฑ้้ ${this.pagesIndex.length} ไธช้กต้ข`);
|
|
1557
|
+
console.log(`๐ ่พๅบ็ฎๅฝ: ${path.resolve(this.outputRoot)}`);
|
|
1558
|
+
console.log(`๐ ็ดขๅผๆไปถ: ${path.join(this.outputRoot, 'index.json')}`);
|
|
1559
|
+
console.log(`๐ HTMLๆฅๅ: ${path.join(this.outputRoot, 'report.html')}`);
|
|
1560
|
+
console.log('\n๐ ๅ ้็ป่ฎก:');
|
|
1561
|
+
console.log(` ่ฟๆปคไฝไปทๅผๅ
็ด : ${this.stats.skipped_elements} ไธช`);
|
|
1562
|
+
console.log(` ่ฝป้ๆขๆต่ทณ่ฟ๏ผ็ๆชๅพ๏ผ: ${this.stats.probe_skipped} ๆฌก`);
|
|
1563
|
+
console.log('='.repeat(60));
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
async manualCrawl(): Promise<void> {
|
|
1567
|
+
if (!this.adb.checkDevice()) return;
|
|
1568
|
+
fs.mkdirSync(this.outputRoot, { recursive: true });
|
|
1569
|
+
|
|
1570
|
+
let pagesIndex: PageRecord[] = [];
|
|
1571
|
+
const indexFile = path.join(this.outputRoot, 'index.json');
|
|
1572
|
+
if (fs.existsSync(indexFile)) {
|
|
1573
|
+
try {
|
|
1574
|
+
pagesIndex = JSON.parse(fs.readFileSync(indexFile, 'utf-8')) as PageRecord[];
|
|
1575
|
+
console.log(`๐ ๅทฒๅ ่ฝฝ ${pagesIndex.length} ไธชๅทฒๆ้กต้ข`);
|
|
1576
|
+
} catch (e) {
|
|
1577
|
+
console.log(`[WARN] ่ฏปๅ index.json ๅคฑ่ดฅ: ${(e as Error).message}๏ผๅฐๅๅปบๆฐ็ดขๅผ`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
let manualCounter = 0;
|
|
1582
|
+
for (const p of pagesIndex) {
|
|
1583
|
+
const mm = (p.page_id ?? '').match(/manual_(\d+)/);
|
|
1584
|
+
if (mm) manualCounter = Math.max(manualCounter, parseInt(mm[1], 10));
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
console.log(`\n๐ ่พๅบ็ฎๅฝ: ${path.resolve(this.outputRoot)}`);
|
|
1588
|
+
console.log(`๐ ๆๅจ้้ๆจกๅผๅทฒๅฏๅจ๏ผmanual ็ผๅทไป ${manualCounter + 1} ๅผๅง๏ผ`);
|
|
1589
|
+
console.log('='.repeat(60));
|
|
1590
|
+
console.log('ๆไฝ่ฏดๆ๏ผ');
|
|
1591
|
+
console.log(' 1. ๅจ่ฎพๅคไธๆๅจๅฏผ่ชๅฐ็ฎๆ ้กต้ข');
|
|
1592
|
+
console.log(' 2. ่พๅ
ฅ้กต้ขๆ่ฟฐ๏ผๅฆ"่ฎพ็ฝฎ้กต > ๅ
ณไบ"๏ผ๏ผ็ถๅๆๅ่ฝฆๅผๅง้้');
|
|
1593
|
+
console.log(' 3. ่พๅ
ฅ q ้ๅบ');
|
|
1594
|
+
console.log('='.repeat(60));
|
|
1595
|
+
|
|
1596
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1597
|
+
const question = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
1598
|
+
|
|
1599
|
+
try {
|
|
1600
|
+
for (;;) {
|
|
1601
|
+
console.log();
|
|
1602
|
+
let desc = (await question('๐ ่พๅ
ฅ้กต้ขๆ่ฟฐ๏ผq ้ๅบ๏ผ: ')).trim();
|
|
1603
|
+
if (desc.toLowerCase() === 'q') {
|
|
1604
|
+
console.log('๐ ้ๅบๆๅจ้้ๆจกๅผ');
|
|
1605
|
+
break;
|
|
1606
|
+
}
|
|
1607
|
+
if (!desc) desc = 'ๆๅจ้้';
|
|
1608
|
+
manualCounter += 1;
|
|
1609
|
+
const { record } = this.capturePage(desc);
|
|
1610
|
+
record.click_path = 'manual';
|
|
1611
|
+
record.description = desc;
|
|
1612
|
+
this.commitManualPage(record, manualCounter);
|
|
1613
|
+
pagesIndex.push(record);
|
|
1614
|
+
fs.writeFileSync(indexFile, JSON.stringify(pagesIndex, null, 2), 'utf-8');
|
|
1615
|
+
this.pagesIndex = pagesIndex;
|
|
1616
|
+
this.generateHtmlReport(pagesIndex);
|
|
1617
|
+
console.log(` ๐ index.json ๅ report.html ๅทฒๆดๆฐ๏ผๅ
ฑ ${pagesIndex.length} ไธช้กต้ข๏ผ`);
|
|
1618
|
+
}
|
|
1619
|
+
} finally {
|
|
1620
|
+
rl.close();
|
|
1621
|
+
}
|
|
1622
|
+
console.log(`\nโ
ๆๅจ้้ๅฎๆ๏ผๅ
ฑ ${pagesIndex.length} ไธช้กต้ข๏ผๅซ่ชๅจ้ๅ๏ผ`);
|
|
1623
|
+
console.log(`๐ ่พๅบ็ฎๅฝ: ${path.resolve(this.outputRoot)}`);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1628
|
+
// CLI
|
|
1629
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1630
|
+
interface CliArgs {
|
|
1631
|
+
package: string;
|
|
1632
|
+
output: string;
|
|
1633
|
+
manual: boolean;
|
|
1634
|
+
device: string;
|
|
1635
|
+
adbPath: string;
|
|
1636
|
+
maxPages: number;
|
|
1637
|
+
maxScrolls: number;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function printHelp(): void {
|
|
1641
|
+
console.log(
|
|
1642
|
+
[
|
|
1643
|
+
'Android ๆจกๆๅจ้กต้ข้ๅๅทฅๅ
ท๏ผๅ ้็๏ผ',
|
|
1644
|
+
'',
|
|
1645
|
+
'Usage: node android_parse_fast.ts --package <pkg> --output <dir> [options]',
|
|
1646
|
+
'',
|
|
1647
|
+
' --package, -p ็ฎๆ App ๅ
ๅ๏ผๅฟ
้๏ผ',
|
|
1648
|
+
' --output, -o ่พๅบ็ฎๅฝ๏ผๅฟ
้๏ผ',
|
|
1649
|
+
' --manual ๆๅจ้้ๆจกๅผ๏ผๆๅจๅฏผ่ชๅฐ้กต้ขๅๆๅ่ฝฆ่งฆๅ้้',
|
|
1650
|
+
` --device ADB ่ฎพๅค serial๏ผ้ป่ฎค: ${DEVICE_SERIAL_DEFAULT}๏ผ`,
|
|
1651
|
+
` --adb-path adb ๅฏๆง่กๆไปถ่ทฏๅพ๏ผ้ป่ฎค: ${ADB_PATH_DEFAULT}๏ผ`,
|
|
1652
|
+
` --max-pages ๆๅค้ๅ้กต้ขๆฐ๏ผ้ป่ฎค: ${MAX_PAGES_DEFAULT}๏ผ`,
|
|
1653
|
+
` --max-scrolls ๆฏไธชๅฏๆปๅจๅฎนๅจๆๅคๆปๅจๆฌกๆฐ๏ผ้ป่ฎค: ${MAX_SCROLLS_DEFAULT}๏ผ`,
|
|
1654
|
+
].join('\n'),
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function parseArgs(argv: string[]): CliArgs {
|
|
1659
|
+
const args: CliArgs = {
|
|
1660
|
+
package: '',
|
|
1661
|
+
output: '',
|
|
1662
|
+
manual: false,
|
|
1663
|
+
device: DEVICE_SERIAL_DEFAULT,
|
|
1664
|
+
adbPath: ADB_PATH_DEFAULT,
|
|
1665
|
+
maxPages: MAX_PAGES_DEFAULT,
|
|
1666
|
+
maxScrolls: MAX_SCROLLS_DEFAULT,
|
|
1667
|
+
};
|
|
1668
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1669
|
+
const a = argv[i];
|
|
1670
|
+
const next = (): string => {
|
|
1671
|
+
if (i + 1 >= argv.length) {
|
|
1672
|
+
console.error(`Missing value for ${a}`);
|
|
1673
|
+
process.exit(2);
|
|
1674
|
+
}
|
|
1675
|
+
return argv[++i];
|
|
1676
|
+
};
|
|
1677
|
+
if (a === '--package' || a === '-p') args.package = next();
|
|
1678
|
+
else if (a === '--output' || a === '-o') args.output = next();
|
|
1679
|
+
else if (a === '--manual') args.manual = true;
|
|
1680
|
+
else if (a === '--device') args.device = next();
|
|
1681
|
+
else if (a === '--adb-path') args.adbPath = next();
|
|
1682
|
+
else if (a === '--max-pages') args.maxPages = parseInt(next(), 10);
|
|
1683
|
+
else if (a === '--max-scrolls') args.maxScrolls = parseInt(next(), 10);
|
|
1684
|
+
else if (a === '-h' || a === '--help') {
|
|
1685
|
+
printHelp();
|
|
1686
|
+
process.exit(0);
|
|
1687
|
+
} else {
|
|
1688
|
+
console.error(`Unknown argument: ${a}`);
|
|
1689
|
+
process.exit(2);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
if (!args.package) {
|
|
1693
|
+
console.error('error: --package/-p is required');
|
|
1694
|
+
process.exit(2);
|
|
1695
|
+
}
|
|
1696
|
+
if (!args.output) {
|
|
1697
|
+
console.error('error: --output/-o is required');
|
|
1698
|
+
process.exit(2);
|
|
1699
|
+
}
|
|
1700
|
+
return args;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
async function main(): Promise<number> {
|
|
1704
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1705
|
+
const adb = new ADBHelper({ adbPath: args.adbPath, deviceSerial: args.device, targetPackage: args.package });
|
|
1706
|
+
const crawler = new PageCrawler({
|
|
1707
|
+
adb,
|
|
1708
|
+
package: args.package,
|
|
1709
|
+
outputDir: args.output,
|
|
1710
|
+
maxPages: args.maxPages,
|
|
1711
|
+
maxScrolls: args.maxScrolls,
|
|
1712
|
+
});
|
|
1713
|
+
if (args.manual) await crawler.manualCrawl();
|
|
1714
|
+
else crawler.crawl();
|
|
1715
|
+
return 0;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
const isEntry = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1719
|
+
if (isEntry) {
|
|
1720
|
+
main().then(
|
|
1721
|
+
(code) => process.exit(code),
|
|
1722
|
+
(err) => {
|
|
1723
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1724
|
+
process.exit(1);
|
|
1725
|
+
},
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
export {
|
|
1730
|
+
parseXml,
|
|
1731
|
+
iterNodes,
|
|
1732
|
+
parseBoundsCenter,
|
|
1733
|
+
getClickableElements,
|
|
1734
|
+
extractNodeKeys,
|
|
1735
|
+
findScrollableContainers,
|
|
1736
|
+
normalizeComponent,
|
|
1737
|
+
stableActivityKey,
|
|
1738
|
+
pageSignature,
|
|
1739
|
+
rootIdentifiedSet,
|
|
1740
|
+
isSameRootPage,
|
|
1741
|
+
findElementOnScreen,
|
|
1742
|
+
focusHasActivityComponent,
|
|
1743
|
+
extractLaunchComponent,
|
|
1744
|
+
PageCrawler,
|
|
1745
|
+
ADBHelper,
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
|