feat(rtos): 安装前补传PNG图标并等待成功回调

这个提交包含在:
sydxky 于 2026-11-14 18:07:35 +08:00 提交
1 父节点 c3649c626f
当前提交 17b51d98ae
18 个文件已更改
+579 -48

没有与搜索条件相匹配的文件

+50
查看文件
@@ -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` 后仍须在手表桌面核对实际图标;若固件有图标缓存,可记录重新打开桌面或由用户重启后的结果。工具不自动重启手表,不承诺所有固件立即刷新,也不把上传成功等同桌面显示成功。
@@ -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 归档字节可复现。
+4 -4
查看文件
@@ -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` 的布尔结果沿用厂商实现,不能替代安装和启动结果。
## 实际验收脚本
+5 -2
查看文件
@@ -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
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application><uses-library android:name="android.test.runner" /></application>
</manifest>
@@ -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(); }
}
}
}
@@ -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<String, long[]> 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(); }
}
@@ -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();
@@ -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(); }
}
}
@@ -18,6 +18,7 @@ final class WearEngine {
private final Handler main = new Handler(Looper.getMainLooper());
private final ExecutorService worker = Executors.newSingleThreadExecutor();
private final Map<String, Binder> callbacks = new ConcurrentHashMap<>();
private final Map<String, File> 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";
}
+20 -3
查看文件
@@ -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)
+1 -1
查看文件
@@ -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
+20
查看文件
@@ -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}}
+1 -2
查看文件
@@ -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)