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 实体手动点击后的官方截图](docs/images/S2-1.0.4-manual-tap.jpg) 上图是 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)