diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15fa335..3af83cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,10 @@
-# 0.2.1(工具包与文档修订)
+# 0.3.0(图标先传、回调确认后安装)
+
+手机 APK 升级为 0.3.0 / 4,探针保持 1.0.4。从 CR 包与 JSON/编译清单识别包名、图标,重新编码最长边 192 的标准 PNG。PNG 先发送至 `/user/jsapps/<包名>.png`,仅终态成功且数字 code=200 后发送 PKG。PGF 使用同 dist 的 PNG 或显式选择;授权不足、图标失败、重复/旧回调及超时均有拦截。
+
+Git2 项目入口先显示可操作页面,再后台读取并进入完整源码页;失败保留重试与项目导航。原生 Git、发行版和权限路由保留。图标桌面呈现需单独真机验收,当前验证范围见图标安装文档。
+
+## 0.2.1(工具包与文档修订)
明确限定 OPPO Watch S / S2 RTOS;S2 已有本项目真机证据,Watch S 依据参考项目兼容,仍待单独验收。手机 APK 保持 0.2.0 / 3,探针保持 1.0.4 / 10004,运行代码和安装包字节未变。修订解压后的文档链接和私有源码归档方式。
diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json
index 511b83b..4de1501 100644
--- a/PUBLIC_SOURCE_MANIFEST.json
+++ b/PUBLIC_SOURCE_MANIFEST.json
@@ -1,16 +1,17 @@
{
"project": "OPPO Watch S / S2 RTOS Debug Bridge",
- "version": "0.2.1",
+ "version": "0.3.0",
"probe": "1.0.4",
"license": "PolyForm-Noncommercial-1.0.0",
"independent_history": true,
"sensitive_findings": [],
"files": {
+ ".gitattributes": "b417160239022500ff4070f251d4701b745c5c545996c82dfdfc2b46d001df5b",
".gitignore": "4cbafe661a8261c06b3a7bc95405c574f07c0090697176ec04f8fb0d7bb14e7f",
- "CHANGELOG.md": "40688e7746a9dfb6aefbd97559ec4e2890f7e4680e3dfa34d6d00dd9335eb1db",
+ "CHANGELOG.md": "5057421807a70e1e1f4e735744cfcdeff457a9c0280c9b00a195e3e38f88b1cc",
"LICENSE": "ffcca38841adb694b6f380647e15f17c446a4d1656fed51a1e2041d064c94cc8",
"NOTICE.md": "0eaa82a4eb8e84b7d89e1e0279815c98dc232565a914ed2f29bab6fc424bbf54",
- "README.md": "18f2e48e45a8dec692dceac6d6d48f9299cc9148b1208bfb411501c7103f6791",
+ "README.md": "28a36b9cb5c6cd42ac105f3062e15394b3148fecc137e924b6fb21ece23537d0",
"build-rtos-game.ps1": "207ff9875af96221559f314364c932fe37bfc22c698ad1afabb9aeec40a8679e",
"com.sydxky.rtos.deviceprobe/.gitignore": "3fc9fbb6fa7795f07ed0f60fe5e3f900e17ea77a793493ef2a3ef582de5fe572",
"com.sydxky.rtos.deviceprobe/LICENSE": "ffcca38841adb694b6f380647e15f17c446a4d1656fed51a1e2041d064c94cc8",
@@ -23,7 +24,7 @@
"com.sydxky.rtos.deviceprobe/src/manifest.json": "338ea23d4271f1e67a8469d9f2df2343d87bbd26df648722da911a16e95bf3cc",
"com.sydxky.rtos.deviceprobe/tests/screen-lifecycle.cjs": "823c6bf1c7e7b514648bb9e2b6c2124f13ef62efcb3e7210e9fd7ce1deecc715",
"docs/ARCHITECTURE.md": "49214e342482b870fa69da4457295904d0abe199dd8f961e4d465dd9b2274904",
- "docs/BUILD.md": "3f1b7050a51ab2fe129922d5ff659b4e3e05a54649b3cc59ce879b9e6fcc309c",
+ "docs/BUILD.md": "85247aa89b40f87d0d0ad297d18f9d34ee1931b0717fb523510daa7544c7e0a0",
"docs/PRIVACY.md": "180601f2f4925dc9bfd2508d12786e0a2bf2d23ea364a7fe58791bc4d6cfc1bb",
"docs/VALIDATION.md": "f915064457ce5680ceec773100931c94a7366602d0e95864206b43c2b026c6e2",
"docs/images/S2-1.0.4-idle-baseline.jpg": "b46fd339dcee4d6ba5406c98847867a6f50f89a64f605cada383125ee6c1c13a",
@@ -31,22 +32,28 @@
"docs/images/S2-1.0.4-manual-tap.jpg": "4e0eacffaefef868382d8d92db2f07864090306f9b3d61114d3cbc9630783aab",
"docs/validation.json": "e57b88b1fed602f4f4e7db9fe52f61ad5c209d53ec7c9646f2356b9d557cd819",
"phone-debug-bridge/.gitignore": "0a8d360b9b497252e065ab7e2fdceb94d2735549d264a3edf112897542b5fddd",
+ "phone-debug-bridge/ICON_INSTALL.md": "1bc0f4fe7cc5a9d846f483d03425d29ec46be187de1344bc50064f05c91fd0cd",
+ "phone-debug-bridge/ICON_VALIDATION_2026-11-14.md": "a7b0952038bc6f373fa663fae849bd151bbc9a5643741e41580336d05930cfb5",
"phone-debug-bridge/LICENSE": "ffcca38841adb694b6f380647e15f17c446a4d1656fed51a1e2041d064c94cc8",
"phone-debug-bridge/NOTICE.md": "0eaa82a4eb8e84b7d89e1e0279815c98dc232565a914ed2f29bab6fc424bbf54",
- "phone-debug-bridge/README.md": "cdb79c48f8e44621de21a02b260f30b4f75827fbb812c26eaccfdfc8d85e56f2",
- "phone-debug-bridge/app/build.gradle": "668cfef7d332c5f428ba2ff56a6dc4a1b0d0c3950bddc7b22411d5d4579364a0",
+ "phone-debug-bridge/README.md": "e0ec67679529b890ce8febec66e043494a3adb166612185581d3e6dff800f5ec",
+ "phone-debug-bridge/app/build.gradle": "ac6a544ea4c72838574d6e528142714ed3a15567cb7961c02adf732dd7362956",
+ "phone-debug-bridge/app/src/androidTest/AndroidManifest.xml": "8fbbadb1fc7735b24b68479cd116cfcd2ed1ac055b6ae9534252f58186629e86",
+ "phone-debug-bridge/app/src/androidTest/java/com/sydxky/rtos/bridge/IconInstallTest.java": "25da64c9542e5d1abe68dff42d68d5b7097944678082180e7869a5704ee275c1",
"phone-debug-bridge/app/src/main/AndroidManifest.xml": "c49aa75bd964369eb56d680cd9430a4cbc43e32cc96030276aabc352cbed130a",
"phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/BridgeService.java": "dd7e5246630bed70cd390219b87780298691da46b369fd4d55629d82acd46bcd",
+ "phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/GlyphixPackage.java": "12835d19e0f330063584e832583856784f4d9036beb638d4f5896fb13b585dbd",
"phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/Journal.java": "6e47206f897d6f9f43a174cb3b0dfd90dbc5539b81dd3213494b88446606e991",
"phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/LocalApi.java": "af731a7cdc91536ec78e1eeb3ddf50f0b8d5a090d2ff5323d18f14422fa7d5c7",
- "phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/MainActivity.java": "7a3888d57a09c719cdd4316135d4c3da1ff47a273e321ac55be87b1ec086fb1a",
+ "phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/MainActivity.java": "70a6cba7167eae766fba727350f20ef08991e87a811c3cad212e753fb19c5d1b",
"phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/UiToolsActivity.java": "9146e1dd1d51a4af0f5ae85af57e72c255514311d4313e5fce8c22d01869f75f",
- "phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WearEngine.java": "36910c95e310c3e6b7e4ab27a6d0216ddeabeee3d9a351614c67668da08e4c1e",
+ "phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WatchIconPng.java": "afe98d55ee78cdfd716aa39bb2d2f3d7e9de98e740ad1f02c56a44481a259662",
+ "phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WearEngine.java": "483967a8e958420ae7bf3151d68f601379830fb71a97aac93824d3bd08f367f3",
"phone-debug-bridge/app/src/main/res/drawable/ic_bridge.xml": "f09152b6b4da93220829473cbce6bb5a8364e585d1ea899a0b16f5e28ea2bb5d",
"phone-debug-bridge/app/src/main/res/xml/data_extraction_rules.xml": "ec0f1c6431c2434ac85c7933dd0fe21c430e9601ec4db70d0112557cbebf9993",
- "phone-debug-bridge/bridge.py": "b940cf000dc9619f50ac59a44fc3fa60aedee6127b25288c05114a5f1c9a599c",
+ "phone-debug-bridge/bridge.py": "75aa8aefb45d4b38b0a21daed65b82d395037ca13d37738fca77304ab1617c46",
"phone-debug-bridge/build.gradle": "de886547be160389c21b4d01d6584abd662e751da42d6bc10b5e7e1d12f64d0e",
- "phone-debug-bridge/build.ps1": "a00ddde57bc932146f62fd307204c6babde7a5847eae0690135214f26a913471",
+ "phone-debug-bridge/build.ps1": "324cfa6244fad56d8f456158c7c50e87c5fc980cdecd4947d0808af72a187213",
"phone-debug-bridge/examples/probe-inspector.json": "8385a334b15d0cbbb45b1507318ca82bc7cdaeee569abd19e1b68fb826136fb0",
"phone-debug-bridge/gradle.properties": "29602ce1e53b5477d1746258b44e370a09735415abdab1d92de86d0201456f45",
"phone-debug-bridge/gradle/wrapper/gradle-wrapper.jar": "e996d452d2645e70c01c11143ca2d3742734a28da2bf61f25c82bdc288c9e637",
@@ -54,18 +61,17 @@
"phone-debug-bridge/gradlew.bat": "0e82fa239f8f4dca4efc83863c16c3b143cb6faab6f50cbefab4ee0bd03e5b5f",
"phone-debug-bridge/settings.gradle": "739bd191eff46009c1ac5b9ab73d523c544d90e36d333f3e222dcc75471efb92",
"phone-debug-bridge/tests/native_ui.py": "3f65b708a1da0a405afa2a0cb767f4af3ad2fc753e6a7cbd793eb93630cd7763",
- "phone-debug-bridge/tests/test_client.py": "70ab60e2efa727f7ce351f77c0eae191a29c9bfd7946b90d5a29e3249a74adc7",
+ "phone-debug-bridge/tests/test_client.py": "16923733cb904ae16d756e5b9e31dabacb8bb8501f354d8af3f37478dd61e963",
"phone-debug-bridge/tests/test_ui.py": "ec9f01413a06eb5bbd3564e9737f18d8f4c936c8a8bf73e24395a013f2e63449",
"phone-debug-bridge/tests/test_verify.py": "29facb49689a41790dad1e31284f2d8b91369cf97d6a3395ff56dc0bb6e4ed16",
"phone-debug-bridge/third_party/Gradle-LICENSE.txt": "9536d88ea948603d18e232a13f5958d67807cd80828036b082bff171d2cf0703",
"phone-debug-bridge/third_party/oppo-watch-s-sideload-MIT.txt": "be0683b824ae7ed6444427c322f6b77ee42e113ef2e88e6240e1557442fac519",
"phone-debug-bridge/ui.py": "556d108aa3ebc88debc7ac5f72d432ccc4c7f9f36a5dce6b67199516a98f26eb",
- "phone-debug-bridge/verify.py": "00f172339e389c4529ca2f6a901cb5b636348d534a472e00c5e782eca3bb5ce2",
+ "phone-debug-bridge/verify.py": "d0dcc57fd5289dbab48a85ab8b3b1680098cde908ed954716ffaa42c606330f2",
"third_party/Gradle-LICENSE.txt": "9536d88ea948603d18e232a13f5958d67807cd80828036b082bff171d2cf0703",
- "third_party/oppo-watch-s-sideload-MIT.txt": "be0683b824ae7ed6444427c322f6b77ee42e113ef2e88e6240e1557442fac519",
- ".gitattributes": "b417160239022500ff4070f251d4701b745c5c545996c82dfdfc2b46d001df5b"
+ "third_party/oppo-watch-s-sideload-MIT.txt": "be0683b824ae7ed6444427c322f6b77ee42e113ef2e88e6240e1557442fac519"
},
- "phone_version": "0.2.0",
+ "phone_version": "0.3.0",
"watch_scope": [
"OPPO Watch S / OWW262 (not device accepted)",
"OPPO Watch S2 / OWW265 (device accepted)"
diff --git a/README.md b/README.md
index ac206cd..c3e02de 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,22 @@
**源码公开 · PolyForm Noncommercial 1.0.0 · 仅限许可允许的非商业用途。** 商业用途须另获许可。完整条款见 [LICENSE](LICENSE),版权及第三方条款见 [NOTICE.md](NOTICE.md)。
-当前工具包发布:**0.2.1(机型范围与文档修订)**;手机桥仍为 **0.2.0 / versionCode 3**,配套探针 **1.0.4 / 10004**。这是开发测试版,APK 安装到手机;手表安装 `bundle.pkg`。已测组合为 P30PRO + OPPO Watch S2 / OWW265,**Watch S / OWW262 的兼容性依据参考项目,尚未由本项目真机验收**;S2 为本次已实测目标。OPPO 其他手表、Android/Wear OS 和 HarmonyOS 手表不在本项目的兼容声明内。
+当前工具包发布:**0.3.0(安装前补传 PNG 图标)**;手机桥为 **0.3.0 / versionCode 4**,配套探针 **1.0.4 / 10004**。这是开发测试版,APK 安装到手机;手表安装 `bundle.pkg`。已测组合为 P30PRO + OPPO Watch S2 / OWW265,**Watch S / OWW262 的兼容性依据参考项目,尚未由本项目真机验收**;S2 为本次已实测目标。OPPO 其他手表、Android/Wear OS 和 HarmonyOS 手表不在本项目的兼容声明内。

上图是 S2 官方截屏导出,用户手动触摸后计数变为 1;画面中的“待实测”是每次运行的请求状态文案。本次另外完成了实体观察:无触摸静置至少 159 秒后仍亮屏。两者均不能替代通用远程输入验证。
+## 0.3.0 安装图标流程
+
+读取包名与图标 → 生成标准 PNG → 传入 `/user/jsapps/<包名>.png` → 等待终态成功且数字 `code=200` → 发送并安装 `.pkg`。包内 PGF 使用同一次构建的 `dist/icon.png`,或 `install <包> --icon <对应PNG>`;手机选包时会提示选择对应 PNG。失败、授权不足、超时或断开时停止安装,重复回调不会重复安装。
+
+[图标补传说明](phone-debug-bridge/ICON_INSTALL.md) · [0.3.0 验证与复现](phone-debug-bridge/ICON_VALIDATION_2026-11-14.md)
+
+API27 原生 Android 模拟器13项、PC工具22项通过。图标顺序测试使用模拟手表端点;**本版 S2 图标实际送达及桌面呈现仍待真机确认**,不将旧版 S2 应用安装或官方截图证据当作新图标通道验收。263 文件通道需要目标应用匹配的 appId/license,默认演示值不保证授权。调试桥不修改手机认证状态或自动重启手表。
+
+Git2 的项目入口先显示项目导航,再后台读取并进入完整源码页;读取失败可重试。克隆、源码历史、发行版与 Issue 仍使用原生 Git/Gitea。
+
## 下载和反馈
- [发布页:APK、探针包、源码与完整工具包](https://git2.sydxky.cn/sydxky/rtos-watch-debug-bridge/releases)
@@ -68,7 +78,7 @@ RTOS 手表不需要 Android ADB。手机必须先在受支持的官方配套应
```powershell
$phone = 'YOUR_PHONE_SERIAL'
-adb -s $phone install -r '..\downloads\RTOS-Phone-Bridge-0.2.0-debug.apk'
+adb -s $phone install -r '..\downloads\RTOS-Phone-Bridge-0.3.0-debug.apk'
python bridge.py --serial $phone connect
python bridge.py --serial $phone wait-link --seconds 45
python bridge.py --serial $phone info
@@ -117,7 +127,7 @@ cd phone-debug-bridge
python -m unittest discover -s tests -v
```
-APK 输出 `phone-debug-bridge/artifacts/RTOS-Phone-Bridge-0.2.0-debug.apk`。这是 Android Debug 签名工具,不是商店上架包;自行构建会使用自己的 debug 证书,与作者发布版可能不同。
+APK 输出 `phone-debug-bridge/artifacts/RTOS-Phone-Bridge-0.3.0-debug.apk`。这是 Android Debug 签名工具,不是商店上架包;自行构建会使用自己的 debug 证书,与作者发布版可能不同。
### Glyphix 探针
@@ -143,7 +153,7 @@ python phone-debug-bridge/tests/native_ui.py --seed 'C:\path\to\initialized-imag
| 文件 | 内容 |
| --- | --- |
-| `RTOS-Phone-Bridge-0.2.0-debug.apk` | 手机 APK,安装到 P30PRO 等 Android 手机 |
+| `RTOS-Phone-Bridge-0.3.0-debug.apk` | 手机 APK,安装到 P30PRO 等 Android 手机 |
| `S2-DeviceProbe-1.0.4-dist.zip` | 圆/方各三文件,安装 bundle.pkg 到手表 |
| `OPPO-Watch-S-S2-Bridge-0.2.1-source.zip` | 独立源码、构建脚本、完整许可和文档 |
| `OPPO-Watch-S-S2-DebugKit-0.2.1.zip` | APK、探针、源码、文档及脱敏证据 |
diff --git a/docs/BUILD.md b/docs/BUILD.md
index 5fd9879..4a882f1 100644
--- a/docs/BUILD.md
+++ b/docs/BUILD.md
@@ -16,3 +16,9 @@
探针圆屏 bundle.pkg SHA-256:`7E4A2F507CE20290AE05DE32A1AEAF6423E8145A165DA52D658E0E8FCB44E5F3`;方屏:`3D12F4BA7218C9B7140731ED484C8DC7EF625FFE4AC28114DB276690A01A9BE4`。公开源码构建结果与本次 S2 实装的圆屏包一致。
S2 的实体亮屏和手动触摸证据另见 [真机记录](VALIDATION.md),不能用模拟器或构建通过替代。
+
+## 0.3.0 补充
+
+手机 APK 0.3.0 / 4 完成 assembleDebug、assembleDebugAndroidTest 与 lintDebug(0 errors / 9 warnings),API27 原生测试13/13、Python22/22通过。新增图标通道的实体 S2 验收未完成,见 [本版记录](../phone-debug-bridge/ICON_VALIDATION_2026-11-14.md)。
+
+最终发行 APK 在 P30PRO 安装并回读 hash 一致,13项Android原生测试通过。真实Wear Engine263已绑定、手表在线;默认演示互联授权为false,因此图标/包发送被停止,实体桌面图标不记为验收通过。
diff --git a/phone-debug-bridge/ICON_INSTALL.md b/phone-debug-bridge/ICON_INSTALL.md
new file mode 100644
index 0000000..2408d5f
--- /dev/null
+++ b/phone-debug-bridge/ICON_INSTALL.md
@@ -0,0 +1,50 @@
+# 安装前补传桌面 PNG 图标(0.3.0)
+
+适用于本调试桥的 OPPO Watch S / S2 Glyphix 安装路径。手机 APK 为 0.3.0 / versionCode 4,Android 最低 API 27;探针保持 1.0.4。保留原有非商业许可与第三方声明。
+
+## 固定顺序
+
+```text
+读取 .pkg 的 CR 资源表与 manifest → 获取实际包名、icon 资源
+ ↓
+解码并等比例缩小为最长边 ≤192 的标准 PNG
+ ↓
+发送文件名 <包名>.png,目标 /user/jsapps/<包名>.png
+ ↓
+等待文件通道终态成功,并确认 JSON 数字 code == 200
+ ↓
+发送并安装 .pkg(remote://mass/bridge_<作业ID>.pkg)
+```
+
+`/user/jsapps/` 是 Glyphix 虚拟文件系统路径。设备资料中对应底层 `/user_data/gx/user/jsapps/`;传输参数始终使用前者,手机不写底层路径。传输复用已经绑定的 Wear Engine 文件通道:263 为 transaction 15,旧接口为 12,参数为只读文件描述符、文件名、目标路径、appId、回调。安装使用 263/16 或旧版/15。
+
+图标进度回调、100% 进度、Binder 调用正常返回都不构成图标已送达。只有终态成功回调中的数字 `code=200` 才继续;失败、缺少 code、字符串 code、异常、超时、断开连接均不会开始包安装。重复或旧会话图标回调不会重复触发安装。包安装自己的最终回调单独决定整个作业是否成功;图标送达不等于应用已安装。
+
+## PNG 与 PGF
+
+`.png` 后缀不代表内容真为 PNG。厂商打包工具会将包内 `assets/icon.png` 转为 `PGF\0`。手机使用 Android 图片解码器识别内容,等比例缩小后重新编码 PNG,不通过改扩展名伪装转换。输入上限 4 MiB、4096×4096;不会拉伸成正方形或放大小图。
+
+- 包内可解码图像:直接生成 PNG。
+- 包内 PGF:PC 优先读取同一 `dist/<设备>/<包名>/icon.png`;可通过 `--icon` 明确指定该应用对应的 PNG。手机单独选 PKG 时会提示再选本包的 PNG,选完才开始作业。
+- 没有对应 PNG、图片损坏或资源表不受支持:安装失败并保留具体原因。支持当前实测 CR 容器、JSON 清单与编译清单 v1;未知格式拒绝安装。
+- 外部图标无法仅凭像素证明归属,请使用同一次构建的完整 dist。不会复用其他应用上一次选过的图标,也不自动使用商店网络图片或通用占位图。
+
+## 使用
+
+```powershell
+# 保留完整 dist 三文件;自动配合同目录 icon.png。
+python bridge.py --serial YOUR_PHONE_SERIAL install 'downloads/dist/<设备>/<包名>/bundle.pkg'
+
+# 包与对应 PNG 分开保存时显式指定。
+python bridge.py --serial YOUR_PHONE_SERIAL install 'downloads/bundle.pkg' --icon 'downloads/my-app-icon.png'
+```
+
+手机端点也支持 `{"command":"install","file":"<已上传PKG名>","iconFile":"<已上传PNG名>"}`;`iconFile` 可省略,此时必须能解码包内图像。`verify.py --probe` 使用同一流程。作业事件 `install.stage` 依次记录 `prepare_icon`、`send_icon`、`install_package`;生成 PNG 和 PKG 快照互相独立,并在终态、断开或超时后清理。常规上传缓存仍可用 `cleanup` 清理。
+
+263 文件通道需要与目标应用匹配的 appId/license 互联授权。默认 `test/test` 是演示值,不能保证授权;授权未完成会停止并说明包尚未发送。保留现有配对和认证,不设置 `authentication_state`,不调用参考项目的 ftprobe 认证旁路。
+
+## 验证边界
+
+本版新增 Android 原生编解码与 Binder 顺序测试;测试使用显式模拟手表端点,不会写实体手表。当前构建的真实 Glyphix 探针包另作解析样本。具体执行结果见 [本版验收](ICON_VALIDATION_2026-11-14.md)。
+
+S2 既有的应用安装、官方截图和前台亮屏证据属于旧安装路径,不能证明新图标通道已送达。收到真实 `code=200` 后仍须在手表桌面核对实际图标;若固件有图标缓存,可记录重新打开桌面或由用户重启后的结果。工具不自动重启手表,不承诺所有固件立即刷新,也不把上传成功等同桌面显示成功。
diff --git a/phone-debug-bridge/ICON_VALIDATION_2026-11-14.md b/phone-debug-bridge/ICON_VALIDATION_2026-11-14.md
new file mode 100644
index 0000000..6642c98
--- /dev/null
+++ b/phone-debug-bridge/ICON_VALIDATION_2026-11-14.md
@@ -0,0 +1,29 @@
+# 0.3.0 图标安装验收(2026-11-14)
+
+| 检查 | 结果与范围 |
+| --- | --- |
+| Android 构建、测试 APK、lint | 成功;0 errors / 9 warnings |
+| 最终 APK 元数据 | `com.sydxky.rtos.bridge`,0.3.0 / 4,minSdk 27、targetSdk 34 |
+| API 27 x86_64 原生 Android 测试 | 13/13;Android 真实图片编解码与 Binder,模拟手表端点,无实体手表写入 |
+| 厂商实际构建包解析 | 探针 1.0.4 圆屏包,7234 字节,CR/编译清单、包名与 PGF 识别通过 |
+| PC 工具标准库测试 | 22/22;含同目录 PNG 自动上传、伪装 PGF 拦截和 HTTP/回调失败保留 |
+| Git2 首屏浏览器 | 9/9;真实首屏及原生页,慢请求与503由浏览器拦截模拟,首次可见约120ms |
+| P30PRO 最终 APK | 用户完成系统确认后安装成功;拉回 APK 的 SHA-256 与最终发行包完全一致 |
+| P30PRO 原生测试 | 最终 APK 上13/13;模拟手表 Binder,不写实体手表 |
+| 真实 Wear Engine | MAS2006006 / 263绑定且手表在线;默认test/test的互联授权为false,新图标通道停止发送 |
+| 实体 S2 新图标通道与桌面 | 未完成;不将模拟 Binder 或旧版 S2 安装证据视为本版图标送达 |
+
+原生测试包含:等比例 600×300 → 192×96 PNG、JSON/编译清单、真实构建包 PGF、越界资源拒绝、进度回调不启动 PKG、仅成功 code200 启动一次、终态失败/非200/无code/非法JSON停止、PGF需显式PNG、断开/超时后回调不安装、授权不足不发送。
+
+复现 Android 测试:在本机 JDK17 / Android SDK 环境运行 `gradlew.bat assembleDebug assembleDebugAndroidTest lintDebug`。将自建探针包 `adb push` 到设备 `/data/local/tmp/rtos-icon-probe.pkg`,再以 `run-as com.sydxky.rtos.bridge` 复制到 `files/real-probe.pkg`,最后执行:
+
+```text
+adb -s <测试设备> shell am instrument -w com.sydxky.rtos.bridge.test/android.test.InstrumentationTestRunner
+python -m unittest discover -s tests -v
+```
+
+测试只连接模拟端点。先安装主 APK 与 `app-debug-androidTest.apk`;在新应用数据目录先 `run-as ... mkdir -p files`。二进制样本采用 `adb push` + `run-as cp` 并核对长度/哈希,不经可能截断控制字节的 shell 标准输入传包。测试前停止正在进行的真实桥接操作,结束后可卸载测试 APK。
+
+测试包不作为用户发行附件。源码和正式调试 APK 均不含厂商 SDK、健康 APK、测试账号、手机序列号或配对数据。许可仍为 PolyForm Noncommercial 1.0.0,不能套用蟹蟹输入项目的 MIT 商用授权。
+
+最终发行/手机回读 APK SHA-256:`8e4f933fa500e56b9742891c32bd7bee18004a798993e67b08fd11dd770f8d21`。独立公开源码重新构建的全部 APK ZIP 条目内容与此发行包一致;不据此声称整个 APK 归档字节可复现。
diff --git a/phone-debug-bridge/README.md b/phone-debug-bridge/README.md
index 9df0f44..8212b37 100644
--- a/phone-debug-bridge/README.md
+++ b/phone-debug-bridge/README.md
@@ -1,4 +1,4 @@
-# OPPO Watch S / S2 RTOS 手机真机调试桥 0.2.0
+# OPPO Watch S / S2 RTOS 手机真机调试桥 0.3.0
自研部分按 [PolyForm Noncommercial 1.0.0](LICENSE) 公开,仅限许可允许的非商业用途;商业用途须另获许可。版权和第三方条款见 [NOTICE.md](NOTICE.md)。独立公开项目:[rtos-watch-debug-bridge](https://git2.sydxky.cn/sydxky/rtos-watch-debug-bridge)。
@@ -25,7 +25,7 @@
使用非 MSIX PowerShell 7。工程不引用运动健康私有 APK 或 SDK,不与现有 Android 手表工程共用构建脚本。
```powershell
-Set-Location -LiteralPath 'C:\path\to\rtos-watch-debug-bridge\phone-debug-bridge'
+Set-Location -LiteralPath '.\phone-debug-bridge'
.\build.ps1 -JavaHome 'C:\path\to\jdk17'
python -m unittest discover -s tests -v
python .\bridge.py --serial YOUR_PHONE_SERIAL setup
@@ -35,7 +35,7 @@ python .\bridge.py --serial YOUR_PHONE_SERIAL link
python .\bridge.py --serial YOUR_PHONE_SERIAL info
```
-工具链:JDK 17、Gradle 8.13、Android Gradle Plugin 8.13.1、compileSdk 35、targetSdk 34、minSdk 27。JDK / Android SDK 可通过 `build.ps1 -JavaHome ... -AndroidSdk ...` 指定。`ANDROID_HOME` 只对当前构建进程设置。产物:`artifacts/RTOS-Phone-Bridge-0.2.0-debug.apk`,versionCode 3。
+工具链:JDK 17、Gradle 8.13、Android Gradle Plugin 8.13.1、compileSdk 35、targetSdk 34、minSdk 27。JDK / Android SDK 可通过 `build.ps1 -JavaHome ... -AndroidSdk ...` 指定。`ANDROID_HOME` 只对当前构建进程设置。产物:`artifacts/RTOS-Phone-Bridge-0.3.0-debug.apk`,versionCode 4。
首次安装时解锁手机并处理华为系统的安装确认,保持运动健康已配对、手表唤醒;Android 13 及以后可允许调试桥通知。手机界面也提供连接、设备信息、选包安装、启动与日志按钮。`setup` 最长等待安装 45 秒;遇到系统确认超时,先在手机完成确认,再重新运行。
@@ -77,7 +77,7 @@ python .\bridge.py --serial YOUR_PHONE_SERIAL cleanup
python .\bridge.py --serial YOUR_PHONE_SERIAL stop
```
-安装只上传包本身;交给 OPPO 的完整 `dist` 仍须保留 `bundle.pkg`、`manifest.json`、`icon.png`。服务端以回调 `success/failed/progress` 更新作业;电脑退出码 2 表示回调失败或超时,1 表示本地 / HTTP 错误,130 表示用户中断。超时不表示手表操作已取消,避免立即重复安装。`check` 的布尔结果沿用厂商实现,不能替代安装和启动结果。
+0.3.0 安装先解析包名与图标,转标准 PNG 后发送 `/user/jsapps/<包名>.png`,收到文件终态成功且数字 `code=200` 后才发送并安装 `.pkg`。完整 `dist` 须保留 `bundle.pkg`、`manifest.json`、`icon.png`;PGF 图标优先使用同目录 PNG,亦可 `install <包> --icon <对应PNG>`。263 文件互联授权不足、图标失败或超时均停止安装,见[完整流程与限制](ICON_INSTALL.md)。服务端以回调 `success/failed/progress` 更新作业;电脑退出码 2 表示回调失败或超时,1 表示本地 / HTTP 错误,130 表示用户中断。超时不表示手表操作已取消,避免立即重复安装。`check` 的布尔结果沿用厂商实现,不能替代安装和启动结果。
## 实际验收脚本
diff --git a/phone-debug-bridge/app/build.gradle b/phone-debug-bridge/app/build.gradle
index 6dc199a..59f82c9 100644
--- a/phone-debug-bridge/app/build.gradle
+++ b/phone-debug-bridge/app/build.gradle
@@ -2,12 +2,15 @@ plugins { id 'com.android.application' }
android {
namespace 'com.sydxky.rtos.bridge'
compileSdk 35
+ useLibrary 'android.test.runner', false
+ useLibrary 'android.test.base', false
defaultConfig {
applicationId 'com.sydxky.rtos.bridge'
minSdk 27
targetSdk 34
- versionCode 3
- versionName '0.2.0'
+ versionCode 4
+ versionName '0.3.0'
+ testInstrumentationRunner 'android.test.InstrumentationTestRunner'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
diff --git a/phone-debug-bridge/app/src/androidTest/AndroidManifest.xml b/phone-debug-bridge/app/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..44d033e
--- /dev/null
+++ b/phone-debug-bridge/app/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/phone-debug-bridge/app/src/androidTest/java/com/sydxky/rtos/bridge/IconInstallTest.java b/phone-debug-bridge/app/src/androidTest/java/com/sydxky/rtos/bridge/IconInstallTest.java
new file mode 100644
index 0000000..35ac470
--- /dev/null
+++ b/phone-debug-bridge/app/src/androidTest/java/com/sydxky/rtos/bridge/IconInstallTest.java
@@ -0,0 +1,152 @@
+package com.sydxky.rtos.bridge;
+
+import android.test.InstrumentationTestCase;
+import android.graphics.*;
+import android.os.*;
+import org.json.*;
+import java.io.*;
+import java.nio.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.lang.reflect.Field;
+
+/** Real Android codec/Binder tests with an explicit fake watch endpoint. No watch writes. */
+public final class IconInstallTest extends InstrumentationTestCase {
+ private File inbox;
+ private WearEngine engine;
+ private Journal journal;
+ private FakeWatch watch;
+ private byte[] png;
+ @Override protected void setUp() throws Exception {
+ super.setUp();
+ inbox = new File(getInstrumentation().getTargetContext().getFilesDir(), "inbox"); inbox.mkdirs();
+ Bitmap bitmap = Bitmap.createBitmap(600, 300, Bitmap.Config.ARGB_8888);
+ bitmap.eraseColor(Color.BLUE); ByteArrayOutputStream output = new ByteArrayOutputStream();
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, output); bitmap.recycle(); png = output.toByteArray();
+ journal = new Journal(); engine = new WearEngine(getInstrumentation().getTargetContext(), journal); watch = new FakeWatch();
+ set("remote", watch); set("version", 2006006); set("descriptor", WearEngine.NEW); set("state", "bound"); set("authorized", true);
+ Files.write(new File(inbox, "icon-test.pkg").toPath(), bundle(png, false));
+ }
+ private void set(String name, Object value) throws Exception { Field field = WearEngine.class.getDeclaredField(name); field.setAccessible(true); field.set(engine, value); }
+ @Override protected void tearDown() throws Exception {
+ engine.close(); new File(inbox, "icon-test.pkg").delete(); new File(inbox, "icon-test.png").delete(); super.tearDown();
+ }
+ private interface Ready { boolean test() throws Exception; }
+ private void waitFor(Ready ready) throws Exception {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (!ready.test()) { if (System.currentTimeMillis() > deadline) fail("Asynchronous callback timed out"); Thread.sleep(20); }
+ }
+ private String install() { return engine.execute(Journal.json("command", "install", "file", "icon-test.pkg")); }
+ public void testPngResizedWithAspectRatio() throws Exception {
+ byte[] encoded = WatchIconPng.encode(png); assertEquals((byte) 0x89, encoded[0]);
+ Bitmap image = BitmapFactory.decodeByteArray(encoded, 0, encoded.length);
+ assertEquals(192, image.getWidth()); assertEquals(96, image.getHeight()); image.recycle();
+ }
+ public void testCrAndCompiledManifest() throws Exception {
+ Files.write(new File(inbox, "icon-test.pkg").toPath(), bundle(png, true));
+ try (GlyphixPackage pkg = new GlyphixPackage(new File(inbox, "icon-test.pkg"))) {
+ assertEquals("com.example.icon", pkg.packageName); assertEquals("assets/icon.png", pkg.iconPath); assertEquals(png.length, pkg.icon().length);
+ }
+ }
+ public void testRealVendorBuiltPackageManifestAndPgf() throws Exception {
+ File source = new File(getInstrumentation().getTargetContext().getFilesDir(), "real-probe.pkg");
+ assertTrue("Push the locally built deviceprobe bundle as files/real-probe.pkg before running", source.isFile());
+ try (GlyphixPackage pkg = new GlyphixPackage(source)) {
+ assertEquals("com.sydxky.rtos.deviceprobe", pkg.packageName);
+ byte[] icon = pkg.icon(); assertEquals('P', icon[0]); assertEquals('G', icon[1]); assertEquals('F', icon[2]);
+ }
+ }
+ public void testInvalidResourceRangeStopsBeforeBinder() throws Exception {
+ byte[] invalid = bundle(png, false); ByteBuffer.wrap(invalid).order(ByteOrder.LITTLE_ENDIAN).putInt(36, Integer.MAX_VALUE);
+ Files.write(new File(inbox, "icon-test.pkg").toPath(), invalid);
+ String id = install(); waitFor(() -> !journal.pending(id)); assertEquals("failed", journal.job(id).getString("state")); assertEquals(0, watch.sent);
+ }
+ public void testInstallWaitsForCode200AndDeduplicates() throws Exception {
+ String id = install(); waitFor(() -> watch.iconResolver != null);
+ assertEquals("com.example.icon.png", watch.name); assertEquals("/user/jsapps/com.example.icon.png", watch.path); assertEquals(0, watch.installed);
+ assertEquals((byte) 0x89, watch.image[0]);
+ watch.callback(3, "{\"code\":200}"); Thread.sleep(100); assertEquals(0, watch.installed);
+ watch.callback(1, "{\"code\":200}"); waitFor(() -> watch.installed == 1);
+ watch.callback(1, "{\"code\":200}"); Thread.sleep(100); assertEquals(1, watch.installed);
+ waitFor(() -> !journal.pending(id)); assertEquals("success", journal.job(id).getString("state"));
+ }
+ public void testNon200StopsPackage() throws Exception { reject(1, "{\"code\":500}"); }
+ public void testFailureStopsPackage() throws Exception { reject(2, "{\"code\":200}"); }
+ public void testMalformedStopsPackage() throws Exception { reject(1, "not-json"); }
+ public void testNoCodeStopsPackage() throws Exception { reject(1, "{}"); }
+ private void reject(int code, String value) throws Exception {
+ String id = install(); waitFor(() -> watch.iconResolver != null); watch.callback(code, value);
+ waitFor(() -> !journal.pending(id)); assertEquals("failed", journal.job(id).getString("state")); assertEquals(0, watch.installed);
+ }
+ public void testPgfNeedsExplicitPng() throws Exception {
+ Files.write(new File(inbox, "icon-test.pkg").toPath(), bundle(new byte[]{'P','G','F',0}, false));
+ String id = install(); waitFor(() -> !journal.pending(id)); assertEquals("failed", journal.job(id).getString("state")); assertEquals(0, watch.sent);
+ Files.write(new File(inbox, "icon-test.png").toPath(), png);
+ String next = engine.execute(Journal.json("command", "install", "file", "icon-test.pkg", "iconFile", "icon-test.png"));
+ waitFor(() -> watch.iconResolver != null); watch.callback(1, "{\"code\":200}");
+ waitFor(() -> !journal.pending(next)); assertEquals(1, watch.installed);
+ }
+ public void testDisconnectedCallbackCannotInstall() throws Exception {
+ String id = install(); waitFor(() -> watch.iconResolver != null);
+ engine.disconnect(); getInstrumentation().waitForIdleSync();
+ watch.callback(1, "{\"code\":200}"); Thread.sleep(150); assertEquals(0, watch.installed); assertFalse(journal.pending(id));
+ }
+ public void testLateCallbackAfterTimeoutCannotInstall() throws Exception {
+ String id = install(); waitFor(() -> watch.iconResolver != null);
+ journal.result(id, "timeout", "test deadline"); watch.callback(1, "{\"code\":200}");
+ Thread.sleep(100); assertEquals(0, watch.installed);
+ }
+ public void testAuthorizationFailureStopsAllTransfers() throws Exception {
+ set("authorized", false); String id = install(); waitFor(() -> !journal.pending(id)); assertEquals(0, watch.sent); assertEquals(0, watch.installed);
+ }
+ private static byte[] bundle(byte[] icon, boolean compiled) throws Exception {
+ byte[] manifest;
+ if (compiled) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.write(ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN).putInt(0x0168b5ae).putInt(0).putInt(0).putInt(1).array());
+ for (String text : new String[]{"com.example.icon", "测试", "1.0.0", "/assets/icon.png"}) {
+ byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
+ out.write(bytes.length & 255); out.write(bytes.length >> 8); out.write(bytes);
+ }
+ manifest = out.toByteArray();
+ } else manifest = "{\"package\":\"com.example.icon\",\"icon\":\"/assets/icon.png\"}".getBytes(StandardCharsets.UTF_8);
+ byte[] names = "manifest.jsonassets/icon.png".getBytes(StandardCharsets.UTF_8);
+ int start = 64 + names.length;
+ ByteBuffer data = ByteBuffer.allocate(start + manifest.length + icon.length).order(ByteOrder.LITTLE_ENDIAN);
+ data.putInt(0x5243).putInt(2).position(32);
+ data.putInt(0).putInt(start).putInt(manifest.length).putInt(13);
+ data.putInt(0).putInt(start + manifest.length).putInt(icon.length).putInt(15);
+ data.put(names).put(manifest).put(icon); return data.array();
+ }
+ private static final class FakeWatch extends Binder {
+ volatile IBinder iconResolver;
+ volatile String name, path;
+ volatile byte[] image;
+ volatile int sent, installed;
+ @Override protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException {
+ data.enforceInterface(WearEngine.NEW);
+ try {
+ if (code == 15) {
+ data.readInt(); ParcelFileDescriptor fd = ParcelFileDescriptor.CREATOR.createFromParcel(data);
+ name = data.readString(); path = data.readString(); data.readString();
+ try (InputStream input = new ParcelFileDescriptor.AutoCloseInputStream(fd)) {
+ ByteArrayOutputStream output = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int n;
+ while ((n = input.read(buffer)) != -1) output.write(buffer, 0, n); image = output.toByteArray();
+ }
+ sent++; iconResolver = data.readStrongBinder();
+ } else if (code == 16) {
+ data.readString(); data.readInt(); ParcelFileDescriptor fd = ParcelFileDescriptor.CREATOR.createFromParcel(data); fd.close();
+ String uri = data.readString(); if (!uri.startsWith("remote://mass/bridge_")) throw new IOException("Wrong package URI");
+ IBinder resolver = data.readStrongBinder(); installed++; resolve(resolver, 1, "{\"code\":200}");
+ }
+ } catch (Exception error) { throw new RemoteException(error.toString()); }
+ reply.writeNoException(); return true;
+ }
+ void callback(int code, String raw) throws Exception { resolve(iconResolver, code, raw); }
+ private static void resolve(IBinder callback, int code, String raw) throws Exception {
+ Parcel data = Parcel.obtain(), reply = Parcel.obtain();
+ try { data.writeInterfaceToken("com.glyphix.mas.WearEngineProgressResolver"); data.writeString(raw); callback.transact(code, data, reply, 0); reply.readException(); }
+ finally { data.recycle(); reply.recycle(); }
+ }
+ }
+}
diff --git a/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/GlyphixPackage.java b/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/GlyphixPackage.java
new file mode 100644
index 0000000..d8ba6fc
--- /dev/null
+++ b/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/GlyphixPackage.java
@@ -0,0 +1,84 @@
+package com.sydxky.rtos.bridge;
+
+import org.json.JSONObject;
+import java.io.*;
+import java.nio.*;
+import java.nio.charset.*;
+import java.util.*;
+
+/** Bounded CR resource reader. Resource names never become local filesystem paths. */
+final class GlyphixPackage implements Closeable {
+ private final RandomAccessFile file;
+ private final Map entries = new HashMap<>();
+ final String packageName, iconPath;
+
+ GlyphixPackage(File source) throws Exception {
+ file = new RandomAccessFile(source, "r");
+ try {
+ long length = file.length();
+ if (length < 32 || length > LocalApi.MAX_FILE) throw new IOException("Invalid package size");
+ byte[] header = new byte[32]; file.readFully(header);
+ ByteBuffer h = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
+ if (h.getInt() != 0x5243) throw new IOException("Expected Glyphix CR package");
+ int count = h.getInt();
+ if (count < 1 || count > 16384 || 32L + count * 16L > length) throw new IOException("Invalid resource count");
+ byte[] index = new byte[count * 16]; file.readFully(index);
+ ByteBuffer table = ByteBuffer.wrap(index).order(ByteOrder.LITTLE_ENDIAN);
+ long[][] records = new long[count][3];
+ long namesEnd = 32L + index.length;
+ for (int i = 0; i < count; i++) {
+ table.getInt(); // Resource-name hash; names remain the authority here.
+ long offset = Integer.toUnsignedLong(table.getInt()), size = Integer.toUnsignedLong(table.getInt());
+ int nameLength = table.getInt();
+ if (nameLength < 1 || nameLength > 1024 || offset > length || size > length - offset)
+ throw new IOException("Invalid resource range");
+ namesEnd += nameLength;
+ if (namesEnd > length) throw new IOException("Truncated resource names");
+ records[i] = new long[]{offset, size, nameLength};
+ }
+ for (long[] record : records) {
+ if (record[0] < namesEnd) throw new IOException("Resource overlaps index");
+ byte[] name = new byte[(int) record[2]]; file.readFully(name);
+ String key = normalize(utf8(name));
+ if (entries.put(key, record) != null) throw new IOException("Duplicate resource");
+ }
+ byte[] manifest = resource("manifest.json", 65536);
+ if (manifest.length > 0 && manifest[0] == '{') {
+ JSONObject value = new JSONObject(utf8(manifest));
+ packageName = value.getString("package"); iconPath = normalize(value.getString("icon"));
+ } else {
+ ByteBuffer m = ByteBuffer.wrap(manifest).order(ByteOrder.LITTLE_ENDIAN);
+ // Observed Glyphix compiled manifest v1: magic/version, flags, versionCode, four strings.
+ if (m.remaining() < 18 || m.getInt() != 0x0168b5ae) throw new IOException("Unsupported compiled manifest version");
+ m.position(16);
+ packageName = string(m); string(m); string(m); iconPath = normalize(string(m));
+ }
+ if (packageName.length() > 150 || !packageName.matches("[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)+"))
+ throw new IOException("Invalid package name");
+ } catch (Exception error) { file.close(); throw error; }
+ }
+ private static String string(ByteBuffer data) throws Exception {
+ if (data.remaining() < 2) throw new EOFException("Truncated manifest");
+ int length = Short.toUnsignedInt(data.getShort());
+ if (length < 1 || length > 4096 || length > data.remaining()) throw new IOException("Invalid manifest string");
+ byte[] bytes = new byte[length]; data.get(bytes);
+ return utf8(bytes);
+ }
+ private static String utf8(byte[] bytes) throws CharacterCodingException {
+ return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();
+ }
+ private static String normalize(String name) throws IOException {
+ if (name.startsWith("/")) name = name.substring(1);
+ if (name.isEmpty() || name.contains("\\") || name.indexOf(0) >= 0 || name.contains(":") ||
+ Arrays.stream(name.split("/", -1)).anyMatch(p -> p.isEmpty() || p.equals(".") || p.equals("..")))
+ throw new IOException("Invalid resource path");
+ return name;
+ }
+ byte[] resource(String name, int maximum) throws IOException {
+ long[] entry = entries.get(name);
+ if (entry == null || entry[1] == 0 || entry[1] > maximum) throw new IOException("Missing or oversized resource: " + name);
+ byte[] bytes = new byte[(int) entry[1]]; file.seek(entry[0]); file.readFully(bytes); return bytes;
+ }
+ byte[] icon() throws IOException { return resource(iconPath, 4 * 1024 * 1024); }
+ @Override public void close() throws IOException { file.close(); }
+}
diff --git a/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/MainActivity.java b/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/MainActivity.java
index c35e0d5..1aacd5b 100644
--- a/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/MainActivity.java
+++ b/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/MainActivity.java
@@ -15,6 +15,7 @@ public final class MainActivity extends Activity {
private final Handler handler = new Handler(Looper.getMainLooper());
private TextView status, events;
private EditText app, license, pkg;
+ private String pendingPackage;
private final ServiceConnection connection = new ServiceConnection() {
@Override public void onServiceConnected(ComponentName name, IBinder binder) { bridge = ((BridgeService.LocalBinder) binder).get(); refresh(); }
@Override public void onServiceDisconnected(ComponentName name) { bridge = null; }
@@ -37,7 +38,7 @@ public final class MainActivity extends Activity {
pkg = field("手表应用包名", "com.sydxky.rtos.deviceprobe"); root.addView(pkg);
button(root, "检查应用", () -> command(Journal.json("command", "check", "package", pkg.getText().toString().trim())));
button(root, "启动应用", () -> command(Journal.json("command", "launch", "package", pkg.getText().toString().trim())));
- button(root, "选择 PKG / RPK 安装", () -> startActivityForResult(new Intent(Intent.ACTION_OPEN_DOCUMENT).setType("*/*").addCategory(Intent.CATEGORY_OPENABLE), 10));
+ button(root, "选择 PKG 并补传图标安装", () -> startActivityForResult(new Intent(Intent.ACTION_OPEN_DOCUMENT).setType("*/*").addCategory(Intent.CATEGORY_OPENABLE), 10));
button(root, "开启手表日志", () -> command(Journal.json("command", "logs", "enabled", true)));
button(root, "关闭手表日志", () -> command(Journal.json("command", "logs", "enabled", false)));
button(root, "截图与 UI 检查", () -> startActivity(new Intent(this, UiToolsActivity.class)));
@@ -74,12 +75,34 @@ public final class MainActivity extends Activity {
}
@Override protected void onActivityResult(int request, int result, Intent data) {
super.onActivityResult(request, result, data);
+ if (request == 12) {
+ String selectedPackage = pendingPackage; pendingPackage = null;
+ if (result != RESULT_OK || data == null || data.getData() == null || selectedPackage == null) return;
+ final android.net.Uri iconUri = data.getData();
+ new Thread(() -> {
+ try (InputStream input = getContentResolver().openInputStream(iconUri)) {
+ if (input == null) throw new IOException("Cannot read PNG");
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int n;
+ while ((n = input.read(buffer)) != -1) {
+ if (bytes.size() + n > 4 * 1024 * 1024) throw new IOException("图标超过 4 MiB");
+ bytes.write(buffer, 0, n);
+ }
+ byte[] png = WatchIconPng.encode(bytes.toByteArray());
+ String iconName = "selected-" + java.util.UUID.randomUUID() + ".png";
+ File icon = new File(getFilesDir(), "inbox/" + iconName);
+ try (OutputStream output = new FileOutputStream(icon)) { output.write(png); }
+ handler.post(() -> command(Journal.json("command", "install", "file", selectedPackage, "iconFile", iconName)));
+ } catch (Exception error) { handler.post(() -> Toast.makeText(this, error.toString(), Toast.LENGTH_LONG).show()); }
+ }, "icon-import").start();
+ return;
+ }
if (request != 10 || result != RESULT_OK || data == null || data.getData() == null) return;
final android.net.Uri uri = data.getData();
new Thread(() -> {
File inbox = new File(getFilesDir(), "inbox");
- File target = new File(inbox, "selected.pkg");
- File temporary = new File(inbox, ".selected");
+ String selectedName = "selected-" + java.util.UUID.randomUUID() + ".pkg";
+ File target = new File(inbox, selectedName);
+ File temporary = new File(inbox, "." + selectedName);
try {
if (!inbox.isDirectory() && !inbox.mkdirs()) throw new IOException("Cannot create inbox");
try (InputStream in = getContentResolver().openInputStream(uri); OutputStream out = new FileOutputStream(temporary)) {
@@ -89,7 +112,17 @@ public final class MainActivity extends Activity {
if (total == 0) throw new IOException("空文件");
}
java.nio.file.Files.move(temporary.toPath(), target.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
- handler.post(() -> command(Journal.json("command", "install", "file", "selected.pkg")));
+ try (GlyphixPackage bundle = new GlyphixPackage(target)) {
+ byte[] image = bundle.icon();
+ boolean pgf = image.length >= 4 && image[0] == 'P' && image[1] == 'G' && image[2] == 'F' && image[3] == 0;
+ if (pgf) handler.post(() -> new AlertDialog.Builder(this).setTitle("选择本应用的 PNG 图标")
+ .setMessage("包内图标为 PGF。请选择该包 dist 目录的 icon.png;图标传输成功后才会安装。")
+ .setPositiveButton("选择 PNG", (dialog, which) -> {
+ pendingPackage = selectedName;
+ startActivityForResult(new Intent(Intent.ACTION_OPEN_DOCUMENT).setType("image/png").addCategory(Intent.CATEGORY_OPENABLE), 12);
+ }).setNegativeButton("取消", null).show());
+ else handler.post(() -> command(Journal.json("command", "install", "file", selectedName)));
+ }
} catch (Exception e) { handler.post(() -> Toast.makeText(this, e.toString(), Toast.LENGTH_LONG).show()); }
finally { if (temporary.exists()) temporary.delete(); }
}, "document-import").start();
diff --git a/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WatchIconPng.java b/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WatchIconPng.java
new file mode 100644
index 0000000..29b1b29
--- /dev/null
+++ b/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WatchIconPng.java
@@ -0,0 +1,33 @@
+package com.sydxky.rtos.bridge;
+
+import android.graphics.*;
+import java.io.*;
+
+/** Re-encode supported Android image formats; never pass PGF bytes off as PNG. */
+final class WatchIconPng {
+ static byte[] encode(byte[] bytes) throws IOException {
+ if (bytes.length == 0 || bytes.length > 4 * 1024 * 1024) throw new IOException("Icon must be at most 4 MiB");
+ if (bytes.length >= 4 && bytes[0] == 'P' && bytes[1] == 'G' && bytes[2] == 'F' && bytes[3] == 0)
+ throw new IOException("PGF icon needs the matching dist/icon.png or an explicitly selected PNG");
+ BitmapFactory.Options bounds = new BitmapFactory.Options(); bounds.inJustDecodeBounds = true;
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.length, bounds);
+ if (bounds.outWidth < 1 || bounds.outHeight < 1 || bounds.outWidth > 4096 || bounds.outHeight > 4096)
+ throw new IOException("Unsupported icon or dimensions exceed 4096");
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inSampleSize = 1;
+ while (Math.max(bounds.outWidth, bounds.outHeight) / options.inSampleSize > 384) {
+ options.inSampleSize *= 2;
+ }
+ Bitmap original = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
+ if (original == null) throw new IOException("Cannot decode icon");
+ Bitmap scaled = original;
+ try {
+ float ratio = Math.min(1f, 192f / Math.max(original.getWidth(), original.getHeight()));
+ scaled = Bitmap.createScaledBitmap(original, Math.max(1, Math.round(original.getWidth() * ratio)),
+ Math.max(1, Math.round(original.getHeight() * ratio)), true);
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ if (!scaled.compress(Bitmap.CompressFormat.PNG, 100, output)) throw new IOException("PNG encoding failed");
+ return output.toByteArray();
+ } finally { if (scaled != original) scaled.recycle(); original.recycle(); }
+ }
+}
diff --git a/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WearEngine.java b/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WearEngine.java
index 0e15b47..8c9fea9 100644
--- a/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WearEngine.java
+++ b/phone-debug-bridge/app/src/main/java/com/sydxky/rtos/bridge/WearEngine.java
@@ -18,6 +18,7 @@ final class WearEngine {
private final Handler main = new Handler(Looper.getMainLooper());
private final ExecutorService worker = Executors.newSingleThreadExecutor();
private final Map callbacks = new ConcurrentHashMap<>();
+ private final Map installs = new ConcurrentHashMap<>();
private volatile IBinder remote;
private volatile String descriptor = OLD, appId = "test", servicePackage = "", state = "disconnected";
private volatile int version, generation;
@@ -125,7 +126,7 @@ final class WearEngine {
}
private void lost(int epoch, String reason) {
if (epoch != generation) return;
- remote = null; state = reason; callbacks.clear(); journal.failPending(reason); journal.event("connect.lost", reason);
+ remote = null; state = reason; callbacks.clear(); journal.failPending(reason); cleanupInstalls(); journal.event("connect.lost", reason);
}
interface Callback { void accept(int code, Parcel data) throws Exception; }
private Binder callback(String name, Callback body) {
@@ -159,12 +160,17 @@ final class WearEngine {
Object value;
try { value = new JSONTokener(raw == null ? "null" : raw).nextValue(); }
catch (JSONException e) { value = raw == null ? "" : raw; }
- journal.result(id, code == 1 ? "success" : code == 2 ? "failed" : "pending", value);
- if (code != 3) callbacks.remove(id);
+ boolean installRejected = command.equals("install") && code == 1 && (!(value instanceof JSONObject) ||
+ !(((JSONObject) value).opt("code") instanceof Number) || ((Number) ((JSONObject) value).opt("code")).doubleValue() != 200);
+ journal.result(id, code == 1 && !installRejected ? "success" : code == 2 || installRejected ? "failed" : "pending", value);
+ if (code != 3) { callbacks.remove(id); cleanupInstall(id); }
});
callbacks.put(id, resolver);
main.postDelayed(() -> {
- if (callbacks.remove(id) != null) journal.result(id, "timeout", "No terminal watch callback; operation may still complete on the watch");
+ if (callbacks.remove(id) != null) {
+ journal.result(id, "timeout", "No terminal watch callback; operation may still complete on the watch");
+ cleanupInstall(id);
+ }
}, 180000);
worker.execute(() -> {
try {
@@ -188,13 +194,9 @@ final class WearEngine {
if (!name.matches("[a-zA-Z0-9._-]{1,100}") || name.startsWith(".")) throw new IllegalArgumentException("Invalid file name");
File file = new File(context.getFilesDir(), "inbox/" + name);
if (!file.isFile() || file.length() == 0) throw new IOException("Upload file first");
+ if (command.equals("install")) { installWithIcon(id, epoch, file, args, resolver); break; }
try (ParcelFileDescriptor fd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)) {
- if (command.equals("install")) transact(modern() ? 16 : 15, p -> {
- if (modern()) p.writeString(appId);
- // The argument is a watch destination URI, not a display filename.
- p.writeInt(1); fd.writeToParcel(p, 0); p.writeString("remote://mass/bridge_" + System.currentTimeMillis() + ".pkg"); p.writeStrongBinder(resolver);
- });
- else transact(modern() ? 15 : 12, p -> {
+ transact(modern() ? 15 : 12, p -> {
p.writeInt(1); fd.writeToParcel(p, 0); p.writeString(name); p.writeString(args.getString("remote")); p.writeString(appId); p.writeStrongBinder(resolver);
});
}
@@ -214,10 +216,88 @@ final class WearEngine {
}
default: throw new IllegalArgumentException(command);
}
- } catch (Exception e) { callbacks.remove(id); journal.result(id, "failed", e.toString()); }
+ } catch (Exception e) { callbacks.remove(id); journal.result(id, "failed", e.toString()); cleanupInstall(id); }
});
return id;
}
+ private void installWithIcon(String id, int epoch, File source, JSONObject args, Binder installResolver) throws Exception {
+ journal.event("install.stage", Journal.json("id", id, "stage", "prepare_icon"));
+ File directory = new File(context.getCacheDir(), "install-" + id);
+ if (!directory.mkdir()) throw new IOException("Cannot prepare install snapshot");
+ installs.put(id, directory);
+ File pkg = new File(directory, "bundle.pkg");
+ java.nio.file.Files.copy(source.toPath(), pkg.toPath());
+ if (source.getName().startsWith("selected-") && !source.delete()) journal.event("install.cleanup_failed", id);
+ final String packageName;
+ byte[] png;
+ try (GlyphixPackage bundle = new GlyphixPackage(pkg)) {
+ packageName = bundle.packageName;
+ if (args.has("iconFile")) {
+ String fallback = args.getString("iconFile");
+ if (!fallback.matches("[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}")) throw new IOException("Invalid icon file");
+ File image = new File(context.getFilesDir(), "inbox/" + fallback);
+ if (!image.isFile() || image.length() > 4 * 1024 * 1024) throw new IOException("Upload matching icon first (max 4 MiB)");
+ png = WatchIconPng.encode(java.nio.file.Files.readAllBytes(image.toPath()));
+ if (image.getName().startsWith("selected-") && !image.delete()) journal.event("install.cleanup_failed", id);
+ } else png = WatchIconPng.encode(bundle.icon());
+ }
+ if (modern() && !authorized) throw new IOException("Icon transfer needs matching appId/license interconnect authorization; package was not sent");
+ File icon = new File(directory, packageName + ".png");
+ try (OutputStream output = new FileOutputStream(icon)) { output.write(png); }
+ java.util.concurrent.atomic.AtomicBoolean terminal = new java.util.concurrent.atomic.AtomicBoolean();
+ Binder iconResolver = callback("WearEngineProgressResolver", (code, data) -> {
+ String raw = data.readString();
+ worker.execute(() -> {
+ if (epoch != generation || !journal.pending(id) || terminal.get()) return;
+ if (code == 3) {
+ journal.event("install.icon_progress", Journal.json("id", id, "stage", "send_icon")); return;
+ }
+ if (!terminal.compareAndSet(false, true)) return;
+ String phase = "send_icon";
+ try {
+ JSONObject value = new JSONObject(raw == null ? "{}" : raw);
+ Object status = value.opt("code");
+ if (code != 1 || !(status instanceof Number) || ((Number) status).doubleValue() != 200)
+ throw new IOException("Icon callback did not confirm code 200; package was not sent");
+ journal.event("install.stage", Journal.json("id", id, "stage", "install_package", "package", packageName,
+ "icon", "/user/jsapps/" + packageName + ".png", "iconCode", 200));
+ callbacks.put(id, installResolver);
+ phase = "install_package";
+ try (ParcelFileDescriptor fd = ParcelFileDescriptor.open(pkg, ParcelFileDescriptor.MODE_READ_ONLY)) {
+ if (epoch != generation || !journal.pending(id)) return;
+ transact(modern() ? 16 : 15, p -> {
+ if (modern()) p.writeString(appId);
+ p.writeInt(1); fd.writeToParcel(p, 0);
+ p.writeString("remote://mass/bridge_" + id + ".pkg"); p.writeStrongBinder(installResolver);
+ });
+ }
+ } catch (Exception error) {
+ callbacks.remove(id); journal.result(id, "failed", Journal.json("stage", phase, "error", error.toString())); cleanupInstall(id);
+ }
+ });
+ });
+ callbacks.put(id, iconResolver);
+ if (epoch != generation || !journal.pending(id)) { cleanupInstall(id); return; }
+ journal.event("install.stage", Journal.json("id", id, "stage", "send_icon", "package", packageName,
+ "icon", "/user/jsapps/" + packageName + ".png"));
+ try (ParcelFileDescriptor fd = ParcelFileDescriptor.open(icon, ParcelFileDescriptor.MODE_READ_ONLY)) {
+ transact(modern() ? 15 : 12, p -> {
+ p.writeInt(1); fd.writeToParcel(p, 0); p.writeString(packageName + ".png");
+ p.writeString("/user/jsapps/" + packageName + ".png"); p.writeString(appId); p.writeStrongBinder(iconResolver);
+ });
+ }
+ }
+ private void cleanupInstall(String id) {
+ if (worker.isShutdown()) return;
+ worker.execute(() -> {
+ File directory = installs.remove(id);
+ if (directory == null) return;
+ File[] files = directory.listFiles();
+ if (files != null) for (File file : files) if (!file.delete()) journal.event("install.cleanup_failed", id);
+ if (!directory.delete()) journal.event("install.cleanup_failed", id);
+ });
+ }
+ private void cleanupInstalls() { for (String id : installs.keySet()) cleanupInstall(id); }
void disconnect() { main.post(this::disconnectNow); }
private void disconnectNow() {
++generation;
@@ -233,7 +313,7 @@ final class WearEngine {
// Its callback is invalidated by the generation guard and replaced on reconnect.
} catch (Exception e) { journal.event("disconnect.cleanup", e.getClass().getSimpleName()); }
});
- remote = null; callbacks.clear(); journal.failPending("Bridge disconnected");
+ remote = null; callbacks.clear(); journal.failPending("Bridge disconnected"); cleanupInstalls();
if (connection != null) { try { context.unbindService(connection); } catch (IllegalArgumentException ignored) {} connection = null; }
heartbeat = rpc = messages = link = auth = null; authorized = false; state = "disconnected";
}
diff --git a/phone-debug-bridge/bridge.py b/phone-debug-bridge/bridge.py
index 9f99f3d..444b258 100644
--- a/phone-debug-bridge/bridge.py
+++ b/phone-debug-bridge/bridge.py
@@ -103,6 +103,19 @@ class Bridge:
raise RuntimeError('Phone upload integrity mismatch')
return name
+ def install(self, filename, icon=None, timeout=180):
+ """Phone validates the package and waits for PNG callback200 before sending PKG."""
+ command = {'command': 'install'}
+ image = Path(icon) if icon else Path(filename).parent / 'icon.png'
+ if icon or image.is_file():
+ if not image.is_file() or not 0 < image.stat().st_size <= 4 * 1024 * 1024:
+ raise ValueError('Matching icon must be 1 byte to 4 MiB')
+ if image.read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
+ raise ValueError('Expected a real PNG icon, not renamed PGF data')
+ command['iconFile'] = self.upload(image)
+ command['file'] = self.upload(filename)
+ return self.command(command, timeout)
+
def main():
parser = argparse.ArgumentParser(description=__doc__)
@@ -112,7 +125,7 @@ def main():
parser.add_argument('--timeout', type=int, default=180)
sub = parser.add_subparsers(dest='action', required=True)
setup = sub.add_parser('setup', help='Install the debug APK and start the bridge')
- setup.add_argument('--apk', default=str(Path(__file__).parent / 'artifacts/RTOS-Phone-Bridge-0.2.0-debug.apk'))
+ setup.add_argument('--apk', default=str(Path(__file__).parent / 'artifacts/RTOS-Phone-Bridge-0.3.0-debug.apk'))
connect = sub.add_parser('connect')
connect.add_argument('--app-id', default='test')
connect.add_argument('--config', help='Local JSON with appId, license and optional service; keep credentials out of Git')
@@ -126,6 +139,8 @@ def main():
child.add_argument('file')
if action == 'send-file':
child.add_argument('--remote', required=True)
+ else:
+ child.add_argument('--icon', type=Path, help='Matching app PNG when the package contains a PGF icon; defaults to sibling icon.png')
sub.add_parser('message').add_argument('message')
logs = sub.add_parser('logs')
logs.add_argument('enabled', choices=['on', 'off'])
@@ -159,6 +174,8 @@ def main():
if result.get('state') != 'success' or result.get('result', {}).get('connected') is not True:
print(json.dumps(result, ensure_ascii=False, indent=2))
return 2
+ elif args.action == 'install':
+ result = bridge.install(args.file, args.icon, args.timeout)
elif args.action == 'events':
cursor = args.after
while True:
@@ -172,9 +189,9 @@ def main():
else:
command = {'command': args.action}
if args.action in ('check', 'launch'): command['package'] = args.package
- elif args.action in ('install', 'send-file'):
+ elif args.action == 'send-file':
command['file'] = bridge.upload(args.file)
- if args.action == 'send-file': command['remote'] = args.remote
+ command['remote'] = args.remote
elif args.action == 'message': command['message'] = args.message
elif args.action == 'logs': command['enabled'] = args.enabled == 'on'
elif args.action == 'rpc': command.update(method=args.method, params=args.params)
diff --git a/phone-debug-bridge/build.ps1 b/phone-debug-bridge/build.ps1
index 6694fc5..20d78d2 100644
--- a/phone-debug-bridge/build.ps1
+++ b/phone-debug-bridge/build.ps1
@@ -12,7 +12,7 @@ try {
& '.\gradlew.bat' --no-daemon assembleDebug lintDebug
if ($LASTEXITCODE -ne 0) { throw "Android build failed ($LASTEXITCODE)" }
$apk = Join-Path $PSScriptRoot 'app\build\outputs\apk\debug\app-debug.apk'
- $dest = Join-Path $PSScriptRoot 'artifacts\RTOS-Phone-Bridge-0.2.0-debug.apk'
+ $dest = Join-Path $PSScriptRoot 'artifacts\RTOS-Phone-Bridge-0.3.0-debug.apk'
New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
Copy-Item -LiteralPath $apk -Destination $dest -Force
Get-FileHash -LiteralPath $dest -Algorithm SHA256
diff --git a/phone-debug-bridge/tests/test_client.py b/phone-debug-bridge/tests/test_client.py
index 8c86b4e..5791c0f 100644
--- a/phone-debug-bridge/tests/test_client.py
+++ b/phone-debug-bridge/tests/test_client.py
@@ -99,6 +99,26 @@ class ClientTests(unittest.TestCase):
with self.assertRaises(ValueError): self.client.upload(file)
self.assertEqual(self.server.requests, [])
+ def test_install_supplies_sibling_png_and_pkg_to_one_job(self):
+ self.server.job_result = {'state': 'success', 'result': {'code': 200}}
+ with tempfile.TemporaryDirectory() as tmp:
+ package = Path(tmp) / 'bundle.pkg'; package.write_bytes(b'CR-test')
+ (Path(tmp) / 'icon.png').write_bytes(b'\x89PNG\r\n\x1a\n' + b'fixture')
+ result = self.client.install(package)
+ posts = [json.loads(row[3]) for row in self.server.requests if row[0] == 'POST']
+ self.assertEqual(len(posts), 1)
+ self.assertEqual(posts[0]['command'], 'install')
+ self.assertIn('iconFile', posts[0])
+ self.assertNotEqual(posts[0]['file'], posts[0]['iconFile'])
+ self.assertEqual(result['state'], 'success')
+
+ def test_renamed_pgf_icon_is_rejected_before_network(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ package = Path(tmp) / 'bundle.pkg'; package.write_bytes(b'CR-test')
+ (Path(tmp) / 'icon.png').write_bytes(b'PGF\x00renamed')
+ with self.assertRaises(ValueError): self.client.install(package)
+ self.assertEqual(self.server.requests, [])
+
def test_wait_link_allows_health_to_reconnect(self):
offline = {'state': 'success', 'result': {'connected': False}}
online = {'state': 'success', 'result': {'connected': True}}
diff --git a/phone-debug-bridge/verify.py b/phone-debug-bridge/verify.py
index 12eb297..dc22599 100644
--- a/phone-debug-bridge/verify.py
+++ b/phone-debug-bridge/verify.py
@@ -95,8 +95,7 @@ def main():
logs = bridge.command({'command': 'logs', 'enabled': True}, 30)
record('rpc_logs_enable', watch_success(logs), logs)
try:
- upload = bridge.upload(args.probe)
- install = bridge.command({'command': 'install', 'file': upload})
+ install = bridge.install(args.probe)
record('watch_install_callback', watch_success(install), install)
if watch_success(install):
launch = bridge.command({'command': 'launch', 'package': 'com.sydxky.rtos.deviceprobe'}, 30)