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

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

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

@@ -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";
}