android2harmony 0.1.5 → 0.1.6
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/agents/self-tester.md +33 -354
- package/dist/index.js +172 -76
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/skills/a2h-resource-convert/SKILL.md +36 -7
- package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +20 -0
- package/skills/a2h-resource-convert/scripts/app_identity.js +741 -0
- package/skills/a2h-ui-transfer/SKILL.md +14 -3
- package/skills/a2h-ui-transfer/references/conversion-procedure.md +5 -30
- package/skills/a2h-ui-transfer/scripts/android_parse_fast.js +137 -20
- package/skills/hmos-fix-build-errors/SKILL.md +1 -1
- package/skills/hmos-incremental-ui-align/README.md +251 -251
- package/skills/hmos-incremental-ui-align/SKILL.md +364 -364
- package/skills/hmos-integration-test/README.md +341 -0
- package/skills/hmos-integration-test/SKILL.md +446 -0
- package/skills/hmos-integration-test/scripts/report-tool.mjs +646 -0
- package/skills/hmos-integration-test/scripts/resolve-metadata-tool.mjs +147 -0
- package/skills/hmos-integration-test/scripts/self-test-runner.mjs +1006 -0
- package/skills/hmos-integration-test/scripts/testcases-tool.mjs +189 -0
- package/skills/hmos-spec-generate/SKILL.md +26 -24
- package/skills/hmos-spec-generate/scripts/parse_requirements.ts +515 -0
- package/skills/hmos-spec-generate/template/REQ.txt +22 -0
- package/skills/hmos-spec-generate/template/REQ.xlsx +0 -0
|
@@ -64,6 +64,14 @@ node <a2h-resource-convert-skill>/scripts/svg_fidelity_check.js \
|
|
|
64
64
|
--harmony-media <harmony_project_dir>/<ui_module>/src/main/resources/base/media
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
**App-identity gate — also before Step 5.** That skill's Toolchain step 2 points `AppScope/app.json5` and `abilities[0]` at the Android launcher icon and app name. Confirm it passed; nothing in Steps 5-7 covers it, and the failure is invisible to every check here — the pages compile and render fine while the app shows the DevEco default icon and is named `label` on the desktop. If resources were converted in an earlier session, run the gate directly (static, no device):
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
node <a2h-resource-convert-skill>/scripts/app_identity.js --check --json <work_dir>/app_identity.json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`GATE: FAIL`, or a missing `app_identity.json` (the step never ran), means re-running that skill's Toolchain step 2 before continuing.
|
|
74
|
+
|
|
67
75
|
## Step 3 — Page Exploration
|
|
68
76
|
|
|
69
77
|
1. Check if `ui_info_root` already contains `page_*` or `manual_*` subdirectories. If yes, validate the existing snapshots as described in Step 4, inform the user "N page snapshots already exist, skipping exploration", and skip device exploration.
|
|
@@ -239,8 +247,9 @@ The unit of work is the **state group** from Step 4.2 — a group of one page (t
|
|
|
239
247
|
|
|
240
248
|
**For each page to convert**:
|
|
241
249
|
|
|
242
|
-
**
|
|
250
|
+
**The per-page conversion procedure lives in `./references/conversion-procedure.md`, and the sub-agent is the one that reads it** — the parent passes its absolute path (input 0 below).
|
|
243
251
|
|
|
252
|
+
0. **`CONVERSION_PROCEDURE` = the absolute path of `./references/conversion-procedure.md` (skill-bundled).** State in the prompt that the sub-agent MUST read this file **first** — before the screenshot, the view tree, or any other input — and follow it end to end. It is the sub-agent's conversion contract, and it carries the Phase 5 evidence requirements plus the **常见误译速查** checklist that nothing in this file duplicates.
|
|
244
253
|
1. The page's `ui_info` absolute path (`{ui_info_root}/page_NNNN_ActivityName`)
|
|
245
254
|
2. `BASE_UI_INFO` = first snapshot directory for this Activity; `CURRENT_UI_INFO` = current page's snapshot directory
|
|
246
255
|
3. `REFERENCE_SCREENSHOTS` = the exact-path-validated screenshot files for the current page (and the base page when different)
|
|
@@ -251,9 +260,11 @@ The unit of work is the **state group** from Step 4.2 — a group of one page (t
|
|
|
251
260
|
7. Resource Mappings: `${harmony_project_dir}/resource_mapping.md` — tell the sub-agent to `Grep <resource-name>` it, never `Read` it (4–22 MB; see Step 2).
|
|
252
261
|
8. **Lottie entries for this page (only if `lottie_by_page[suggested_page_name]` is non-empty)**. Attach the matching rows verbatim as a JSON block labelled `LOTTIE_ENTRIES_FOR_THIS_PAGE`, one object per animation. When there are no entries for this page, do NOT include the block and do NOT mention Lottie in the prompt — the sub-agent should not spend context on it. Uses these entries per `conversion-procedure.md` Phase 4.5 to wire `@ohos/lottie` into the generated ArkTS page.
|
|
253
262
|
|
|
254
|
-
The prompt MUST state that all paths in `REFERENCE_SCREENSHOTS` and `REFERENCE_VIEW_TREES` were verified by exact-path reads. It MUST NOT override them with a natural-language claim such as "there is no screenshot".
|
|
263
|
+
The prompt MUST state that all paths in `REFERENCE_SCREENSHOTS` and `REFERENCE_VIEW_TREES` were verified by exact-path reads. It MUST NOT override them with a natural-language claim such as "there is no screenshot". Before dispatching, check that the assembled prompt text literally contains `conversion-procedure.md` and both reference-path lists. This is a text check on the prompt you are about to send, not a recollection of having meant to include them — the omission is silent, and a sub-agent cannot report a contract it was never given.
|
|
264
|
+
|
|
265
|
+
### Sub-agent contract — include all five in every per-page prompt
|
|
255
266
|
|
|
256
|
-
|
|
267
|
+
**0. Read `CONVERSION_PROCEDURE` before any other input, and treat it as the conversion contract.**
|
|
257
268
|
|
|
258
269
|
**1. Write each file to disk the moment it is complete. Never batch writes to the end.**
|
|
259
270
|
This is not a style preference; it is measured. In a batch where transport errors killed 4 of 5 sub-agents mid-run, the agents that wrote incrementally kept all their code and only their final report was lost (cheap for the parent to reconstruct), while the agents that held finished code to write at the end lost ~25-30 minutes of correct analysis each and had to restart from zero. A sub-agent transcript may be unresumable, so unwritten work is unrecoverable. Order the writes so the least-dependent files (model, viewmodel) land first.
|
|
@@ -72,14 +72,6 @@
|
|
|
72
72
|
最后两行(V1 专有陷阱)**,再按需读 `{mvvm_dir}/` 下相关文件。**若本次要在既有 V1 工程里新增代码**,必读
|
|
73
73
|
`{mvvm_v2_dir}/状态管理V1向V2迁移与混用指导.md` 以确认不触发混用。
|
|
74
74
|
|
|
75
|
-
> **V1 分支的必读例外**:本次若要**新写或修改任何 `@Observed` 类**(VM / Model),
|
|
76
|
-
> 则 `{mvvm_dir}/@Track装饰器:class对象属性级更新.md` **属必读,不再是按需**。
|
|
77
|
-
> 原因:`@Track` 是 all-or-nothing 语义(类里一旦出现任何 `@Track`,UI 读到的每个成员都必须是
|
|
78
|
-
> `@Track` 字段,否则首帧抛 `BusinessError 140110`),而 **V1 没有 `@Computed`**,
|
|
79
|
-
> 派生值该怎么暴露给 UI 在 V1 侧没有正面答案 —— 用裸 `get x()` 是极自然却必崩的写法,
|
|
80
|
-
> 且它**编译通过、零告警、字段侧完备性检查全过**。速查表已给出完整判据,但该文档给出官方示例与边界。
|
|
81
|
-
> 这条例外存在的意义:懒加载省掉的是**重复**阅读,不该省掉**唯一**记载某条硬约束的文档。
|
|
82
|
-
|
|
83
75
|
V1 目录清单(其余按需查,勿通读):
|
|
84
76
|
- `MVVM模式(V1).md`
|
|
85
77
|
- `@Track装饰器:class对象属性级更新.md`
|
|
@@ -264,15 +256,13 @@ dialog/fragment/adapter 等必须从主页面拆出去到 `{harmony_project_dir}
|
|
|
264
256
|
|
|
265
257
|
**图标来源分级 —— 逐个图标判定并在报告中标注级别**:
|
|
266
258
|
|
|
267
|
-
`res/drawable` 里没有对应 `<vector>` 是**常态而非异常**:Compose 的 `Icons.Outlined.*`、Phosphor、Feather 等是编译进库的 `ImageVector`,Step 2 只转 `res/`,转不出它们。此时必须走分级,不得自行发明降级方式。
|
|
268
|
-
|
|
269
259
|
| 级别 | 判据 | 做法 |
|
|
270
260
|
|---|---|---|
|
|
271
|
-
| **L1** | `res/drawable/` 有对应 `<vector>` |
|
|
272
|
-
| **L2** | 库编译图标,`res/` 无文件,但资源转换阶段已提取出 SVG |
|
|
273
|
-
| **L3** | L1/L2 都拿不到(含转换报告 **Unavailable** 里列出的符号) | 报告中显式登记 `已降级`:缺哪个图标、当前用**等尺寸空 Row/Column 占位**(禁止 emoji
|
|
261
|
+
| **L1** | `res/drawable/` 有对应 `<vector>` | **先全量列出 `{harmony_project_dir}/entry/src/main/resources/base/media` 的文件清单**(`Get-ChildItem <media> -Name`,**禁止 `Select-Object -First N` / `head -N` 等截断式列举** —— 截断结果按字母序开头,通常全是 `abc_*` 之类 AppCompat 内置资源,与"目录里没有该图标"完全同形),再按 Android 侧 `res/drawable/` 的文件名与该清单 join;命中即用 Step 2 的转换产物。**L1 未做过这次全量列举,不得进入 L2** |
|
|
262
|
+
| **L2** | 库编译图标,`res/` 无文件,但资源转换阶段已提取出 SVG | **在`resource_mapping.md` 中用 Grep 定位 `### Code-Defined Vector Icons` 章节(含 Converted / Unavailable 分栏),不要 Read 整个文件** —— `resource_mapping.md` 按设计可能有 4~22 MB。检索方式**首选按符号名直接 grep**(`grep -n "ic_get_app\|ic_query_stats\|ic_glasses" <报告>`,也可用 Android 源码符号名 `grep -n "Icons.Outlined.GetApp" <文件>`);`grep -n -A 80 "^### Code-Defined Vector Icons" <报告>` 作为**补充**手段 —— 该章节标题在部分资源转换器产物中并不存在,命中失败不代表图标不可得,此时必须回退到按符号名 grep,并注意已转换的库图标也可能登记在通用资源表里(形如 `| res/drawable/ic_glasses_24dp.xml | ... | converted |`)而不在该章节内。命中后**按 Android 源码符号名精确匹配映射行**(如 `Icons.Outlined.GetApp` → 映射行的 Compose Reference 列),用其中登记的 HarmonyOS Target media,并在报告里注明来自哪个库的哪个符号(可溯源) |
|
|
263
|
+
| **L3** | L1/L2 都拿不到(含转换报告 **Unavailable** 里列出的符号) | 报告中显式登记 `已降级`:缺哪个图标、当前用**等尺寸空 Row/Column 占位**(禁止 emoji/文字字形/Unicode)、影响哪个位置 |
|
|
274
264
|
|
|
275
|
-
判定顺序固定为 L1 → L2 → L3,**不要自行推断某个图标"应该"长什么样**。L2 匹配必须用 Android 源码符号与映射表 join,**不得**按"看起来像"选——当一页有多个相近图标(download/get_app/install 在 Material 里都存在)时按相似度选必选错。`Unavailable` 里的每一项都已经是上游确认拿不到的,直接归 L3
|
|
265
|
+
判定顺序固定为 L1 → L2 → L3,**不要自行推断某个图标"应该"长什么样**。L2 匹配必须用 Android 源码符号与映射表 join,**不得**按"看起来像"选——当一页有多个相近图标(download/get_app/install 在 Material 里都存在)时按相似度选必选错。`Unavailable` 里的每一项都已经是上游确认拿不到的,直接归 L3,不要再尝试补。**检索空命中不构成"该资源不存在"的证据**:`grep` 返回 `No files found`、或列目录结果里没看到,都只说明**该模式/该次列举没有匹配**——文件很可能存在(可用 `Test-Path` 单独验证),措辞却与"资源不存在"完全同形(同 L2 格里 Read 被静默截断的坑)。宣布任一图标为 L3 之前,必须换至少一种检索方式(换匹配模式、换检索路径、或改为全量列目录)复现空结果,仅凭一次空命中即落 L3 判定失败。**L3 占位禁止用 emoji/文字字形**(见下文禁止项 1),改用等尺寸空位,以便后续补实时清晰可辨。
|
|
276
266
|
|
|
277
267
|
**禁止的三种降级**(编译全过、静态引用检查全过,但视觉必错):
|
|
278
268
|
|
|
@@ -305,12 +295,6 @@ dialog/fragment/adapter 等必须从主页面拆出去到 `{harmony_project_dir}
|
|
|
305
295
|
- 对参考截图中每个可见 Image/Video/Text,记录 Android 来源、HarmonyOS 目标资源和 ArkUI 代码引用。
|
|
306
296
|
- 已迁移但未在代码中消费的关键可见媒体(例如首页 PRESET 视频/背景)必须补齐绑定;不得用纯色或占位渐变替代后仍判定完成。
|
|
307
297
|
|
|
308
|
-
**布局**:查 layout mapping ref。
|
|
309
|
-
|
|
310
|
-
**组件**:查 atomic component mapping ref。
|
|
311
|
-
|
|
312
|
-
**交互**:查 interaction mapping ref。
|
|
313
|
-
|
|
314
298
|
**始终先查映射文件**(项目专属、覆盖更全),覆盖内置知识。
|
|
315
299
|
|
|
316
300
|
**单位与样式**:
|
|
@@ -471,7 +455,7 @@ view tree 部件与原子组件自带装饰、①用截图误判+④用「与
|
|
|
471
455
|
- **文字对齐证据**:逐个列出直接含 `Text`(或含渲染 `Text` 的 `@Builder` 调用)的 `Column`/`Row`,标注它是否显式声明了**决定水平位置的那个轴**,以及该取值的 Android 依据。
|
|
472
456
|
**两种容器的该轴属性不同名,必须按容器类型取**:`Column` 的水平方向是交叉轴 → `.alignItems(HorizontalAlign.*)`;`Row` 的水平方向是主轴 → `.justifyContent(FlexAlign.*)`。
|
|
473
457
|
`Row` 上的 `.alignItems(VerticalAlign.*)` 管的是**纵轴**,**不满足本项** —— 它常常本身完全正确(对应 Android 的 `verticalAlignment`/`gravity="center_vertical"`),正因如此最容易被当成"对齐已经写了"而放过:一个正确的纵轴设置掩盖了缺失的横轴设置,两处单看都没错。实测漏检形态是日期分隔行、章节标题居中而 Android 齐左。判据:宽度撑满的 `Row` 里若有收缩宽度的 `Text`,就必须能指出是哪个属性把它定在了左边。**ArkUI `Column` 默认居中、Android `LinearLayout`/Compose `Column` 默认 start** —— Android 侧未写 gravity 时必须显式译成 `.alignItems(HorizontalAlign.Start)`,不能同样"不写"。未显式声明且未说明依据者判定失败(居中确为本意时,注明理由即可通过,例如空态插图、按钮内图标)。此项漏检的典型表现是章节标题、列表项文字整体居中而 Android 是齐左,且逐属性对照会确认成"翻译正确"。
|
|
474
|
-
- **图标来源分级证据**:对每个可见图标,标注它属于 Phase 4.3 的 L1 / L2 / L3 哪一级;L2 须给出库与符号名,L3 须给出降级说明。**L3
|
|
458
|
+
- **图标来源分级证据**:对每个可见图标,标注它属于 Phase 4.3 的 L1 / L2 / L3 哪一级;L2 须给出库与符号名,L3 须给出降级说明。**L3 未登记即判定失败**。**每个 L3 还须出示检索痕迹而非结论断言**:逐个列出该图标实际执行过的 L1 全量列目录命令与其输出摘要、L2 按符号名 grep 命令与其输出,二者缺任一即判定失败;一句「库图标不可提取」「全部降级为占位」之类的概括**不构成**已检索的证据。并逐个确认没有使用被禁止的三种降级(emoji/文字字形、语义不符的 drawable 顶替、凭记忆手写 SVG)。**并且必须确认内容语义正确**:对代码中引用的每个 SVG,**打开文件读 path/circle/rect 几何**,与参考截图中该图标的形状比对(参考侧形状可用 `measure_pack.js --probe <screenshot.png> --rect <图标 bounds> --mode ascii|runs` 读出;`--probe` 只接受 PNG,不能传 SVG);形状不符即判定失败。这一步是**人工比对**——静态检查里的 `media-element-count-mismatch` 只数几何元素个数,抓不到元素数相同而轮廓不同的情形。**禁止只抽查部分后对未检查文件下全称断言**。对单个图标尺寸,列出 measure_pack 实测值(如有)与代码字面值,偏差 > 30% 即判定失败。
|
|
475
459
|
- **资源引用错用**:资源引用是否与 Android UI 一致;不存在的资源要重新查找正确名; "不允许用任何emoji、硬编码图标"。
|
|
476
460
|
**并且必须确认资源内容本身与 Android 源语义等价 —— 文件存在不等于内容正确**。对由 Android `<vector>` 转换而来的 SVG,打开文件核对:
|
|
477
461
|
- `clip-path` 是否保留(丢失会改变图形的位置、尺寸与可见范围;若 path 铺满 viewport 而靠 clip 裁形,丢失后会渲染成一个纯色矩形)
|
|
@@ -507,9 +491,7 @@ view tree 部件与原子组件自带装饰、①用截图误判+④用「与
|
|
|
507
491
|
**按本 Phase 开头的轮次规则跑验证迭代(2 轮必跑,第 2 轮有修改则续跑至零修改),并输出验证报告**。
|
|
508
492
|
|
|
509
493
|
### 静态检查器(可选自查,父流程 Step 6.0 会统一跑)
|
|
510
|
-
|
|
511
494
|
上表中属于**纯词法**的若干项已实现为脚本,可在本页写完后自查一遍(静态、无需设备与 SDK,秒级):
|
|
512
|
-
|
|
513
495
|
```
|
|
514
496
|
node <skill>/scripts/arkts_static_check.js --ets <harmony_project_dir>/{ui_module}/src/main/ets \
|
|
515
497
|
--resources <harmony_project_dir>/{ui_module}/src/main/resources \
|
|
@@ -551,13 +533,6 @@ node <skill>/scripts/arkts_static_check.js --ets <harmony_project_dir>/{ui_modul
|
|
|
551
533
|
| **在原子组件之外重建它自带的装饰** | Android view tree 暴露了框架复合控件的内部件(如 SearchView 的 `search_plate` 下划线),逐个映射显得更「忠实」,而 ArkUI 的 `Search` 已经自带该装饰 | 同一视觉元素被画两遍;手写节点的盒与原子组件的圆角/内缩盒不重合时,多出的装饰会落在组件**外部**,表现为「多了一条线」 | 先列出目标原子组件默认已绘制的装饰,再逐个确认无同义手写兄弟节点(见 Phase 4.3 的部件归属表) |
|
|
552
534
|
| **(V1)用 `get x()` 给 UI 暴露派生值** | `@Track` 的完备性直觉是**面向字段**的:「所有参与渲染的**属性**都加 `@Track`」——15 个字段全加了,检查项自然判过。而 getter **不是字段**,落在这条直觉的射程之外。更隐蔽的是 **V1 没有 `@Computed`**(那是 V2 能力),派生值在 V1 侧没有正面出路,于是「裸 getter + 反正读取会重新求值」成了极自然的推理 —— 该推理关于**响应性**是对的,关于**合法性**是错的 | **首帧运行时崩溃**:`BusinessError 140110: Illegal usage of not @Track'ed property 'x' on UI!`。编译通过、零告警、`[GATE]` 全清、字段侧完备性检查全过、逐属性对照亦「忠实」;只有真机拉起页面才会显形(`/data/log/faultlog/faultlogger/jscrash-<bundle>`) | 对每个「用了 `@Track` 的 `@Observed` 类」grep `get [a-zA-Z]*(`:命中即缺陷。改成**普通方法**(`title(): string`,UI 侧 `vm.title()`;原型方法不经过 `@Track` 代理),或提升为真正的 `@Track` 字段。检查的对象应是**「`build()` 里读到的每一个成员」**,而不是「类里声明的每一个属性」。GATE 规则 `track-class-getter-in-ui` 机械点名 |
|
|
553
535
|
|
|
554
|
-
> 上表最后四行与其他行有本质区别:**缺陷源于两个平台对同一概念的定义不同,而非译者写错了值**。
|
|
555
|
-
> 其中末两行更进一步 —— 它们是**两条各自正确的规则组合出的错**:SVG 按规则写成了 `fill="none"`,
|
|
556
|
-
> 染色按直觉用了 `fillColor`;view tree 的部件按规则逐个映射了,而目标原子组件本就自带该装饰。
|
|
557
|
-
> 单独审查任何一侧都查不出问题,只有把"文件内容 + 使用方式"放在一起看才会暴露。
|
|
558
|
-
> 逐属性核对反而会把它确认成"翻译正确",所以不能指望"再读一遍代码"发现 ——
|
|
559
|
-
> 只能靠上面给出的**针对性核对方法**(把 padding 折进声明尺寸、查 `.d.ts` 默认值)逐条排除。
|
|
560
|
-
|
|
561
536
|
## 全局准则
|
|
562
537
|
|
|
563
538
|
- **严格保真**:layout 结构、组件层级、资源引用必须与 Android 源一一对应;不增删/重排 UI 元素
|
|
@@ -348,6 +348,26 @@ function focusHasActivityComponent(activityInfo) {
|
|
|
348
348
|
return /[\w.]+\/[\w.]+/.test(activityInfo['current_focus'] ?? '');
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
+
/**
|
|
352
|
+
* 从已采集的首屏 activity_info 里取"根页面 Activity"的短名,用于 restartToRoot 判定是否已回到根页。
|
|
353
|
+
*
|
|
354
|
+
* 必须用**实际落地的首屏**,而不是启动组件:入口可能是 trampoline
|
|
355
|
+
* (com.ichi2.anki/.IntentHandler),落地页却是 DeckPicker,用入口短名做关键字会永远判不到根页。
|
|
356
|
+
*/
|
|
357
|
+
function rootKeywordFromActivityInfo(activityInfo) {
|
|
358
|
+
for (const key of ['resumed_activity', 'current_focus', 'focused_app']) {
|
|
359
|
+
const s = activityInfo?.[key] ?? '';
|
|
360
|
+
const m = s.match(/[\w.]+\/[\w.$]+/);
|
|
361
|
+
if (!m) continue;
|
|
362
|
+
const comp = normalizeComponent(m[0]);
|
|
363
|
+
const act = comp.split('/')[1] ?? '';
|
|
364
|
+
const parts = act.split('.');
|
|
365
|
+
const short = parts[parts.length - 1];
|
|
366
|
+
if (short) return short;
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
351
371
|
/**
|
|
352
372
|
* 从 activity 信息提取启动组件 [package, activity]。
|
|
353
373
|
* 优先 current_focus(通常是全限定 Activity),回退 resumed_activity(可能是相对名 `.ui.Main`,
|
|
@@ -483,9 +503,37 @@ class ADBHelper {
|
|
|
483
503
|
}
|
|
484
504
|
}
|
|
485
505
|
const out = res.stdout ?? Buffer.alloc(0);
|
|
506
|
+
// adb 的 `am start` 等子命令失败时把原因写到 stderr(如 SecurityException:
|
|
507
|
+
// Permission Denial —— 目标 Activity 未 exported)。只读 stdout 会把整条原因丢掉,
|
|
508
|
+
// 上层只能看到"没到达目标页面"这种误导性结论。
|
|
509
|
+
const err = (res.stderr ?? Buffer.alloc(0)).toString('utf-8').trim();
|
|
510
|
+
if (err && res.status !== 0) {
|
|
511
|
+
console.log(` [ADB-ERR] ${args.join(' ')}`);
|
|
512
|
+
for (const line of err.split(/\r?\n/).slice(0, 4)) console.log(` ${line}`);
|
|
513
|
+
}
|
|
486
514
|
return out.toString('utf-8').trim();
|
|
487
515
|
}
|
|
488
516
|
|
|
517
|
+
/**
|
|
518
|
+
* 问系统要目标包真正可启动的 launcher 入口组件。
|
|
519
|
+
*
|
|
520
|
+
* 不能用"当前前台窗口的 Activity"代替:很多 App 的首屏 Activity 是
|
|
521
|
+
* `exported="false"` 的(AnkiDroid 的 DeckPicker 就是),真正的入口是一个
|
|
522
|
+
* exported 的 trampoline(IntentHandler)。拿首屏 Activity 去 `am start -n`
|
|
523
|
+
* 会被系统以 SecurityException 拒绝,App 根本起不来。
|
|
524
|
+
*
|
|
525
|
+
* @returns {string|null} 形如 "com.ichi2.anki/.IntentHandler",解析失败返回 null
|
|
526
|
+
*/
|
|
527
|
+
resolveLauncherActivity(pkg) {
|
|
528
|
+
const out = this.shell(['cmd', 'package', 'resolve-activity', '--brief', pkg], 20);
|
|
529
|
+
const lines = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
530
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
531
|
+
const m = lines[i].match(/^([\w.]+)\/([\w.$]+)$/);
|
|
532
|
+
if (m && m[1] === pkg) return lines[i];
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
489
537
|
shell(args, timeout = 15) {
|
|
490
538
|
return this.run(['shell', ...args], timeout);
|
|
491
539
|
}
|
|
@@ -526,6 +574,34 @@ class ADBHelper {
|
|
|
526
574
|
return info;
|
|
527
575
|
}
|
|
528
576
|
|
|
577
|
+
/**
|
|
578
|
+
* 等目标包在**当前焦点窗口**里稳定下来,返回是否等到。
|
|
579
|
+
*
|
|
580
|
+
* 与 waitForIdle() 的区别:这里要求焦点窗口非 null 且属于目标包。
|
|
581
|
+
* 透明 trampoline(如 AnkiDroid 的 IntentHandler)过场时 mCurrentFocus=null,
|
|
582
|
+
* 对 waitForIdle() 来说"稳定",但对 uiautomator 来说不可 dump。
|
|
583
|
+
*/
|
|
584
|
+
waitForPackageForeground(pkg, timeoutSec = 12) {
|
|
585
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
586
|
+
let stableCount = 0;
|
|
587
|
+
let prev = null;
|
|
588
|
+
while (Date.now() < deadline) {
|
|
589
|
+
const info = this.getCurrentActivity();
|
|
590
|
+
const focus = info['current_focus'] ?? '';
|
|
591
|
+
// 必须是"有焦点窗口 + 焦点属于目标包",null 焦点不算落地。
|
|
592
|
+
const ok = !/=null\b/.test(focus) && focus.includes(pkg) && /[\w.]+\/[\w.$]+/.test(focus);
|
|
593
|
+
if (ok && focus === prev) {
|
|
594
|
+
stableCount += 1;
|
|
595
|
+
if (stableCount >= 2) return true;
|
|
596
|
+
} else {
|
|
597
|
+
stableCount = ok ? 1 : 0;
|
|
598
|
+
}
|
|
599
|
+
prev = ok ? focus : null;
|
|
600
|
+
sleep(0.4);
|
|
601
|
+
}
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
|
|
529
605
|
waitForIdle() {
|
|
530
606
|
const deadline = Date.now() + this.idleTimeout * 1000;
|
|
531
607
|
let prevFocus = null;
|
|
@@ -581,10 +657,9 @@ class ADBHelper {
|
|
|
581
657
|
*/
|
|
582
658
|
pickBestLine(lines) {
|
|
583
659
|
if (lines.length === 0) return undefined;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
660
|
+
// 不要"优先挑含目标包的行":目标包不在前台时,那样会翻出一条陈旧或次要的
|
|
661
|
+
// 含包名行,伪造出"目标 App 在前台"的假象,使 isInTargetApp() 误判为真,
|
|
662
|
+
// 于是把别的 App 的界面当目标页面采下来。前台判定必须忠实反映当前状态。
|
|
588
663
|
const withComponent = lines.find((l) => !/=null\b/.test(l) && /[\w.]+\/[\w.]+/.test(l));
|
|
589
664
|
return withComponent ?? lines[0];
|
|
590
665
|
}
|
|
@@ -592,7 +667,10 @@ class ADBHelper {
|
|
|
592
667
|
getCurrentActivity() {
|
|
593
668
|
const info = {};
|
|
594
669
|
const actOut = this.shell(['dumpsys', 'activity', 'activities']);
|
|
595
|
-
|
|
670
|
+
// 必须用 `window displays` 而不是 `window`:后者的输出里可能夹着
|
|
671
|
+
// `Last ANR continued` 段落中的历史 DISPLAY CONTENTS 快照,pickBestLine()
|
|
672
|
+
// 会取到那份陈旧的 mCurrentFocus(实测为一个无关 App)。
|
|
673
|
+
const winOut = this.shell(['dumpsys', 'window', 'displays']);
|
|
596
674
|
|
|
597
675
|
const resumedLines = [];
|
|
598
676
|
for (const line of actOut.split(/\r?\n/)) {
|
|
@@ -1033,6 +1111,8 @@ class PageCrawler {
|
|
|
1033
1111
|
sleep(1.0);
|
|
1034
1112
|
this.adb.shell(['am', 'start', '-n', component, '--activity-clear-task', '--activity-clear-top']);
|
|
1035
1113
|
this.adb.waitForIdle();
|
|
1114
|
+
// 入口是 trampoline 时,落地页要再等一拍才可 dump(同 crawl() 里的首次启动)。
|
|
1115
|
+
this.adb.waitForPackageForeground(launchPackage);
|
|
1036
1116
|
|
|
1037
1117
|
for (let retry = 0; retry < 4; retry++) {
|
|
1038
1118
|
if (isAtRoot()) return true;
|
|
@@ -1040,7 +1120,7 @@ class PageCrawler {
|
|
|
1040
1120
|
sleep(2);
|
|
1041
1121
|
}
|
|
1042
1122
|
|
|
1043
|
-
console.log(' [WARN] force-stop 后未到达根页面(App
|
|
1123
|
+
console.log(' [WARN] force-stop 后未到达根页面(App 未起来 / 停在非根页面,见上方 [ADB-ERR])');
|
|
1044
1124
|
console.log(' [INFO] 尝试 back + 点击路径恢复...');
|
|
1045
1125
|
this.adb.pressBack();
|
|
1046
1126
|
if (isAtRoot()) return true;
|
|
@@ -1410,7 +1490,7 @@ ${cards}
|
|
|
1410
1490
|
const notInApp = !this.isInTargetApp(activityInfo);
|
|
1411
1491
|
if (notInApp || !focusHasComponent) {
|
|
1412
1492
|
if (notInApp) {
|
|
1413
|
-
console.log(`当前不在目标 App (${this.package})
|
|
1493
|
+
console.log(`当前不在目标 App (${this.package}) 内,force-stop 后启动...`);
|
|
1414
1494
|
} else {
|
|
1415
1495
|
console.log(
|
|
1416
1496
|
`当前前台为弹窗/overlay(${activityInfo['current_focus'] ?? 'N/A'}),force-stop 后重新启动以回到干净根页面...`,
|
|
@@ -1420,6 +1500,11 @@ ${cards}
|
|
|
1420
1500
|
sleep(0.5);
|
|
1421
1501
|
this.adb.shell(['monkey', '-p', this.package, '-c', 'android.intent.category.LAUNCHER', '1']);
|
|
1422
1502
|
this.adb.waitForIdle();
|
|
1503
|
+
// trampoline 过场期间 mCurrentFocus=null,waitForIdle() 会误判为已空闲;
|
|
1504
|
+
// 必须再等到目标包真正持有焦点窗口,否则采到的是不可 dump 的中间态。
|
|
1505
|
+
if (!this.adb.waitForPackageForeground(this.package)) {
|
|
1506
|
+
console.log(` [WARN] 等待 ${this.package} 取得焦点窗口超时,继续尝试`);
|
|
1507
|
+
}
|
|
1423
1508
|
activityInfo = this.adb.getCurrentActivity();
|
|
1424
1509
|
if (!this.isInTargetApp(activityInfo)) {
|
|
1425
1510
|
console.log(`[ERR] 启动后仍未进入目标 App (${this.package}),请检查包名是否正确`);
|
|
@@ -1427,24 +1512,32 @@ ${cards}
|
|
|
1427
1512
|
}
|
|
1428
1513
|
}
|
|
1429
1514
|
|
|
1430
|
-
|
|
1515
|
+
// launchPackage 只认 --package 传入值。绝不从前台窗口反推:
|
|
1516
|
+
// pickBestLine() 在目标包不在前台时会退回"任何带 包名/Activity 的行",
|
|
1517
|
+
// 于是 launchPackage 可能变成一个无关 App —— 而它会被 `am force-stop` 强杀。
|
|
1518
|
+
const launchPackage = this.package;
|
|
1431
1519
|
let launchActivity = null;
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1520
|
+
|
|
1521
|
+
const resolved = this.adb.resolveLauncherActivity(launchPackage);
|
|
1522
|
+
if (resolved) {
|
|
1523
|
+
launchActivity = resolved.split('/')[1];
|
|
1524
|
+
console.log(`启动 Activity: ${resolved} (来自 resolve-activity)`);
|
|
1436
1525
|
} else {
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
)
|
|
1526
|
+
// 兜底:resolve-activity 没给出结果时才退回前台窗口,且必须同包。
|
|
1527
|
+
const comp = extractLaunchComponent(activityInfo);
|
|
1528
|
+
if (comp && comp[0] === launchPackage) {
|
|
1529
|
+
launchActivity = comp[1];
|
|
1530
|
+
console.log(`启动 Activity: ${launchPackage}/${launchActivity} (来自前台窗口,兜底)`);
|
|
1531
|
+
} else {
|
|
1532
|
+
console.log(
|
|
1533
|
+
`[WARN] 无法确定 ${launchPackage} 的启动 Activity: focus=${activityInfo['current_focus'] ?? ''} resumed=${activityInfo['resumed_activity'] ?? ''}`,
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1440
1536
|
}
|
|
1441
1537
|
|
|
1538
|
+
// 根页面关键字在首屏采集之后才能确定(入口可能是 trampoline,落地页另有其名),
|
|
1539
|
+
// 这里先声明,赋值见下方 checkpoint / 首屏采集两个分支。
|
|
1442
1540
|
let rootActivityKeyword = null;
|
|
1443
|
-
if (launchActivity) {
|
|
1444
|
-
const parts = launchActivity.split('.');
|
|
1445
|
-
rootActivityKeyword = parts[parts.length - 1];
|
|
1446
|
-
console.log(`根 Activity 关键字: ${rootActivityKeyword}`);
|
|
1447
|
-
}
|
|
1448
1541
|
|
|
1449
1542
|
fs.mkdirSync(this.outputRoot, { recursive: true });
|
|
1450
1543
|
console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}\n`);
|
|
@@ -1468,16 +1561,31 @@ ${cards}
|
|
|
1468
1561
|
const rootXml = fs.readFileSync(rootXmlPath, 'utf-8');
|
|
1469
1562
|
rootIdentity = rootIdentifiedSet(p.activity_info ?? {}, rootXml);
|
|
1470
1563
|
}
|
|
1564
|
+
rootActivityKeyword = rootKeywordFromActivityInfo(p.activity_info ?? {});
|
|
1565
|
+
if (rootActivityKeyword) console.log(`根 Activity 关键字: ${rootActivityKeyword} (来自 ${p.page_id})`);
|
|
1471
1566
|
break;
|
|
1472
1567
|
}
|
|
1473
1568
|
}
|
|
1474
1569
|
} else {
|
|
1570
|
+
// 首屏必须在"目标包持有焦点窗口"时采。只用 isInTargetApp() 不够:
|
|
1571
|
+
// 它对 focus=null + resumed=<trampoline> 也返回 true,而那一刻 uiautomator
|
|
1572
|
+
// dump 不出任何东西(view.xml 0 字节、0 个可点击元素)。
|
|
1573
|
+
if (!this.adb.waitForPackageForeground(this.package)) {
|
|
1574
|
+
const preInfo = this.adb.getCurrentActivity();
|
|
1575
|
+
console.log(
|
|
1576
|
+
`[ERR] 采集首屏前目标 App (${this.package}) 未取得焦点窗口:focus=${preInfo['current_focus'] ?? 'N/A'} resumed=${preInfo['resumed_activity'] ?? 'N/A'}`,
|
|
1577
|
+
);
|
|
1578
|
+
console.log(' 首屏是后续所有页面的基准,采错会污染整轮结果,已中止。');
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1475
1581
|
const { record, xmlContent, clickable, scrollContainerBounds } = this.capturePage('初始页面');
|
|
1476
1582
|
const pageId = this.commitPage(record);
|
|
1477
1583
|
record.click_path = [];
|
|
1478
1584
|
fs.writeFileSync(path.join(this.outputRoot, pageId, 'meta.json'), JSON.stringify(record, null, 2), 'utf-8');
|
|
1479
1585
|
const sig = pageSignature(record.activity_info, xmlContent);
|
|
1480
1586
|
rootIdentity = rootIdentifiedSet(record.activity_info, xmlContent);
|
|
1587
|
+
rootActivityKeyword = rootKeywordFromActivityInfo(record.activity_info);
|
|
1588
|
+
if (rootActivityKeyword) console.log(`根 Activity 关键字: ${rootActivityKeyword} (来自首屏)`);
|
|
1481
1589
|
this.visitedSignatures.add(sig);
|
|
1482
1590
|
this.pathSignatures[JSON.stringify([])] = sig;
|
|
1483
1591
|
this.pagesIndex.push(record);
|
|
@@ -1546,6 +1654,15 @@ ${cards}
|
|
|
1546
1654
|
continue;
|
|
1547
1655
|
}
|
|
1548
1656
|
|
|
1657
|
+
// input tap/swipe 是全局事件,打到的是**当时的前台窗口**,不绑定 App。
|
|
1658
|
+
// 若此刻前台已不是目标 App(导航半路跳走、弹窗被系统抢焦点等),
|
|
1659
|
+
// 点下去就是点在别人家界面上,且随后的 dump/截图也会把它录进 ui_info。
|
|
1660
|
+
const preTapInfo = this.adb.getCurrentActivity();
|
|
1661
|
+
if (!this.isInTargetApp(preTapInfo)) {
|
|
1662
|
+
console.log(` [SKIP] 点击前前台已不是目标 App(${preTapInfo['current_focus'] ?? 'N/A'}),跳过`);
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1549
1666
|
const center = foundElem.center;
|
|
1550
1667
|
const labelText = foundElem.text || foundElem['content-desc'] || foundElem['resource-id'] || 'unknown';
|
|
1551
1668
|
console.log(`\n👆 点击: [${labelText}] @ ${JSON.stringify(center)}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hmos-fix-build-errors
|
|
3
|
-
description: Build a HarmonyOS project via CLI and automatically fix compile errors in a loop until the build succeeds. Default unsigned HAP; pass --signed to build a signed HAP (signing config must already exist in the project's build-profile.json5).
|
|
3
|
+
description: Build a HarmonyOS project via CLI and automatically fix compile errors in a loop until the build succeeds. Default unsigned HAP; pass --signed to build a signed HAP (signing config must already exist in the project's build-profile.json5). Allowed to be called only in Android-to-HarmonyOS migration scenarios.
|
|
4
4
|
argument-hint: <harmony_project_dir> [deveco-studio-path] [--signed]
|
|
5
5
|
allowed-tools: Agent, Read, Write, Edit, Glob, Grep, Bash
|
|
6
6
|
type: tool
|