feat(rtos): 安装前补传PNG图标并等待成功回调
这个提交包含在:
1 父节点
c3649c626f
当前提交
17b51d98ae
18 个文件已更改
+579
-48
没有与搜索条件相匹配的文件
@@ -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";
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户