feat(rtos): 首次发布手机真机调试桥与S2探针
这个提交包含在:
当前提交
2f0f4b9702
59 个文件已更改
+4303
没有与搜索条件相匹配的文件
@@ -0,0 +1,17 @@
|
||||
plugins { id 'com.android.application' }
|
||||
android {
|
||||
namespace 'com.sydxky.rtos.bridge'
|
||||
compileSdk 35
|
||||
defaultConfig {
|
||||
applicationId 'com.sydxky.rtos.bridge'
|
||||
minSdk 27
|
||||
targetSdk 34
|
||||
versionCode 3
|
||||
versionName '0.2.0'
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
buildTypes { debug { minifyEnabled false } }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<queries><intent><action android:name="com.xfaith.glyphix.WEAR_ENGINE_ACTION" /></intent></queries>
|
||||
<application android:label="RTOS 真机调试桥" android:allowBackup="false" android:icon="@drawable/ic_bridge"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar" android:supportsRtl="true">
|
||||
<activity android:name=".MainActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".UiToolsActivity" android:exported="false" />
|
||||
<service android:name=".BridgeService" android:exported="false" android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.sydxky.rtos.bridge;
|
||||
|
||||
import android.app.*;
|
||||
import android.content.*;
|
||||
import android.os.*;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
public final class BridgeService extends Service {
|
||||
final Journal journal = new Journal();
|
||||
WearEngine engine;
|
||||
private LocalApi api;
|
||||
final class LocalBinder extends Binder { BridgeService get() { return BridgeService.this; } }
|
||||
@Override public IBinder onBind(Intent intent) { return new LocalBinder(); }
|
||||
@Override public void onCreate() {
|
||||
super.onCreate();
|
||||
startBridge();
|
||||
}
|
||||
private void startBridge() {
|
||||
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||
nm.createNotificationChannel(new NotificationChannel("bridge", "RTOS 调试连接", NotificationManager.IMPORTANCE_LOW));
|
||||
PendingIntent open = PendingIntent.getActivity(this, 0, new Intent(this, MainActivity.class), PendingIntent.FLAG_IMMUTABLE);
|
||||
PendingIntent stop = PendingIntent.getService(this, 1, new Intent(this, BridgeService.class).setAction("stop"), PendingIntent.FLAG_IMMUTABLE);
|
||||
startForeground(1, new Notification.Builder(this, "bridge").setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle("RTOS 真机调试桥运行中").setContentText("USB ADB → 手机 → 已配对手表")
|
||||
.setContentIntent(open).addAction(new Notification.Action.Builder(null, "停止", stop).build()).build());
|
||||
engine = new WearEngine(this, journal);
|
||||
try {
|
||||
File secret = new File(getFilesDir(), "bridge-token");
|
||||
if (!secret.exists()) {
|
||||
byte[] random = new byte[32]; new SecureRandom().nextBytes(random);
|
||||
String token = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
Files.write(secret.toPath(), token.getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
String token = new String(Files.readAllBytes(secret.toPath()), StandardCharsets.US_ASCII);
|
||||
api = new LocalApi(engine, journal, token, getFilesDir());
|
||||
journal.event("bridge.started", "127.0.0.1:" + LocalApi.PORT);
|
||||
} catch (Exception e) { journal.event("bridge.failed", e.toString()); stopSelf(); }
|
||||
}
|
||||
@Override public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && "stop".equals(intent.getAction())) {
|
||||
shutdown(); stopForeground(STOP_FOREGROUND_REMOVE); stopSelf();
|
||||
} else if (api == null) startBridge();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
private void shutdown() {
|
||||
if (api != null) { try { api.close(); } catch (IOException ignored) {} api = null; }
|
||||
if (engine != null) engine.close();
|
||||
}
|
||||
@Override public void onDestroy() { shutdown(); super.onDestroy(); }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.sydxky.rtos.bridge;
|
||||
|
||||
import android.util.Log;
|
||||
import org.json.*;
|
||||
import java.util.*;
|
||||
|
||||
final class Journal {
|
||||
private long sequence;
|
||||
private final ArrayDeque<JSONObject> events = new ArrayDeque<>();
|
||||
private final LinkedHashMap<String, JSONObject> jobs = new LinkedHashMap<>();
|
||||
static JSONObject json(Object... pairs) {
|
||||
JSONObject out = new JSONObject();
|
||||
try { for (int i = 0; i < pairs.length; i += 2) out.put(pairs[i].toString(), pairs[i+1]); }
|
||||
catch (JSONException e) { throw new IllegalArgumentException(e); }
|
||||
return out;
|
||||
}
|
||||
synchronized void event(String type, Object data) {
|
||||
Object snapshot = data;
|
||||
if (data instanceof JSONObject) try { snapshot = new JSONObject(data.toString()); } catch (JSONException ignored) {}
|
||||
JSONObject item = json("seq", ++sequence, "time", System.currentTimeMillis(), "type", type, "data", snapshot);
|
||||
events.addLast(item);
|
||||
while (events.size() > 1000) events.removeFirst();
|
||||
// Contents stay in the app's authenticated event stream, not the shared Android log.
|
||||
Log.i("RtosBridge", type);
|
||||
}
|
||||
synchronized String newJob(String command) {
|
||||
long pending = jobs.values().stream().filter(j -> "pending".equals(j.optString("state"))).count();
|
||||
if (pending >= 8) throw new IllegalStateException("Too many pending operations; wait or reconnect");
|
||||
String id = UUID.randomUUID().toString();
|
||||
jobs.put(id, json("id", id, "command", command, "state", "pending", "created", System.currentTimeMillis()));
|
||||
while (jobs.size() > 200) {
|
||||
String completed = jobs.entrySet().stream().filter(e -> !"pending".equals(e.getValue().optString("state")))
|
||||
.map(Map.Entry::getKey).findFirst().orElse(null);
|
||||
if (completed == null) break;
|
||||
jobs.remove(completed);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
synchronized boolean pending(String id) { return jobs.containsKey(id) && "pending".equals(jobs.get(id).optString("state")); }
|
||||
synchronized void result(String id, String state, Object value) {
|
||||
if (!pending(id)) return;
|
||||
JSONObject job = jobs.get(id);
|
||||
try { job.put("state", state); job.put("result", value); }
|
||||
catch (JSONException e) { throw new IllegalArgumentException(e); }
|
||||
event("job." + state, job);
|
||||
}
|
||||
synchronized void failPending(String reason) {
|
||||
for (String id : new ArrayList<>(jobs.keySet())) result(id, "failed", reason);
|
||||
}
|
||||
synchronized JSONObject job(String id) {
|
||||
JSONObject value = jobs.get(id);
|
||||
if (value == null) return null;
|
||||
try { return new JSONObject(value.toString()); } catch (JSONException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
synchronized JSONArray events(long after) {
|
||||
JSONArray out = new JSONArray();
|
||||
for (JSONObject e : events) if (e.optLong("seq") > after) out.put(e);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.sydxky.rtos.bridge;
|
||||
|
||||
import org.json.*;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Authenticated loopback endpoint, reachable from the PC using adb forward. */
|
||||
final class LocalApi implements Closeable {
|
||||
static final int PORT = 18765;
|
||||
static final int MAX_FILE = 32 * 1024 * 1024;
|
||||
private final ServerSocket server;
|
||||
private final ThreadPoolExecutor clients = new ThreadPoolExecutor(2, 2, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(4));
|
||||
private final Set<Socket> sockets = ConcurrentHashMap.newKeySet();
|
||||
private final WearEngine engine;
|
||||
private final Journal journal;
|
||||
private final String token;
|
||||
private final File inbox;
|
||||
private volatile boolean closed;
|
||||
|
||||
LocalApi(WearEngine engine, Journal journal, String token, File files) throws IOException {
|
||||
this.engine = engine; this.journal = journal; this.token = token;
|
||||
inbox = new File(files, "inbox");
|
||||
if (!inbox.isDirectory() && !inbox.mkdirs()) throw new IOException("Cannot create inbox");
|
||||
server = new ServerSocket();
|
||||
server.setReuseAddress(true);
|
||||
server.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT), 4);
|
||||
new Thread(this::accept, "bridge-loopback").start();
|
||||
}
|
||||
private void accept() {
|
||||
while (!closed) try {
|
||||
Socket socket = server.accept();
|
||||
socket.setSoTimeout(15000); sockets.add(socket);
|
||||
try { clients.execute(() -> handle(socket)); }
|
||||
catch (RejectedExecutionException e) { sockets.remove(socket); socket.close(); }
|
||||
} catch (IOException e) { if (!closed) journal.event("api.error", e.getClass().getSimpleName()); }
|
||||
}
|
||||
private String line(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
for (int i = 0; i < 8192; i++) {
|
||||
int c = in.read();
|
||||
if (c < 0) throw new EOFException();
|
||||
if (c == '\n') return out.toString(StandardCharsets.US_ASCII.name()).replace("\r", "");
|
||||
out.write(c);
|
||||
}
|
||||
throw new IOException("Header line too long");
|
||||
}
|
||||
private void handle(Socket socket) {
|
||||
try (Socket ignored = socket) {
|
||||
try {
|
||||
InputStream in = new BufferedInputStream(socket.getInputStream());
|
||||
String[] request = line(in).split(" ");
|
||||
if (request.length != 3) { respond(socket, 400, Journal.json("error", "Bad request")); return; }
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
int size = 0;
|
||||
while (true) {
|
||||
String l = line(in); size += l.length();
|
||||
if (size > 8192) throw new IOException("Headers too long");
|
||||
if (l.isEmpty()) break;
|
||||
int colon = l.indexOf(':');
|
||||
if (colon < 1) throw new IOException("Bad header");
|
||||
String key = l.substring(0, colon).trim().toLowerCase(Locale.ROOT);
|
||||
if (headers.put(key, l.substring(colon+1).trim()) != null) throw new IOException("Duplicate header");
|
||||
}
|
||||
if (headers.containsKey("origin") || !MessageDigest.isEqual(("Bearer " + token).getBytes(StandardCharsets.US_ASCII),
|
||||
headers.getOrDefault("authorization", "").getBytes(StandardCharsets.US_ASCII))) {
|
||||
respond(socket, 401, Journal.json("error", "Unauthorized")); return;
|
||||
}
|
||||
if (headers.containsKey("transfer-encoding")) { respond(socket, 400, Journal.json("error", "Content-Length required")); return; }
|
||||
URI uri = URI.create(request[1]);
|
||||
String method = request[0], path = uri.getPath();
|
||||
if (method.equals("GET") && path.equals("/status")) { respond(socket, 200, engine.status()); return; }
|
||||
if (method.equals("GET") && path.equals("/events")) {
|
||||
String q = uri.getQuery();
|
||||
long after = q == null ? 0 : Long.parseLong(q.replaceFirst("^after=", ""));
|
||||
respond(socket, 200, Journal.json("events", journal.events(after))); return;
|
||||
}
|
||||
if (method.equals("GET") && path.startsWith("/jobs/")) {
|
||||
JSONObject job = journal.job(path.substring(6));
|
||||
respond(socket, job == null ? 404 : 200, job == null ? Journal.json("error", "Job expired or unknown") : job); return;
|
||||
}
|
||||
int length = Integer.parseInt(headers.getOrDefault("content-length", "0"));
|
||||
if (length < 0 || length > MAX_FILE) { respond(socket, 413, Journal.json("error", "File too large")); return; }
|
||||
if (method.equals("PUT") && path.startsWith("/files/")) {
|
||||
String name = path.substring(7);
|
||||
if (!name.matches("[a-zA-Z0-9._-]{1,100}") || name.startsWith(".") || length == 0) {
|
||||
respond(socket, 400, Journal.json("error", "Invalid file name or empty file")); return;
|
||||
}
|
||||
synchronized (inbox) {
|
||||
File target = new File(inbox, name);
|
||||
File[] files = inbox.listFiles();
|
||||
long total = 0; int count = 0;
|
||||
if (files != null) for (File f : files) if (!f.equals(target)) { total += f.length(); count++; }
|
||||
if (count >= 4 || total + length > 64L * 1024 * 1024) {
|
||||
respond(socket, 413, Journal.json("error", "Inbox full; use cleanup after transfers finish")); return;
|
||||
}
|
||||
File temp = new File(inbox, ".upload");
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try {
|
||||
try (OutputStream out = new FileOutputStream(temp)) {
|
||||
byte[] buffer = new byte[16384]; int left = length;
|
||||
while (left > 0) {
|
||||
int n = in.read(buffer, 0, Math.min(left, buffer.length));
|
||||
if (n < 0) throw new EOFException();
|
||||
out.write(buffer, 0, n); digest.update(buffer, 0, n); left -= n;
|
||||
}
|
||||
}
|
||||
StringBuilder sha = new StringBuilder();
|
||||
for (byte b : digest.digest()) sha.append(String.format(Locale.ROOT, "%02x", b & 255));
|
||||
String expected = headers.get("x-sha256");
|
||||
if (expected != null && !expected.equalsIgnoreCase(sha.toString())) throw new IOException("SHA-256 mismatch");
|
||||
Files.move(temp.toPath(), target.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
respond(socket, 200, Journal.json("file", name, "size", length, "sha256", sha.toString()));
|
||||
} finally { if (temp.exists()) temp.delete(); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!method.equals("POST") || length > 65536) { respond(socket, 404, Journal.json("error", "Unknown route")); return; }
|
||||
byte[] body = new byte[length];
|
||||
new DataInputStream(in).readFully(body);
|
||||
JSONObject args = new JSONObject(length == 0 ? "{}" : new String(body, StandardCharsets.UTF_8));
|
||||
switch (path) {
|
||||
case "/connect": engine.connect(args); respond(socket, 202, Journal.json("accepted", true)); break;
|
||||
case "/disconnect": engine.disconnect(); respond(socket, 202, Journal.json("accepted", true)); break;
|
||||
case "/command": respond(socket, 202, Journal.json("id", engine.execute(args))); break;
|
||||
case "/cleanup":
|
||||
synchronized (inbox) {
|
||||
File[] files = inbox.listFiles(); int deleted = 0;
|
||||
if (files != null) for (File f : files) if (f.isFile() && f.delete()) deleted++;
|
||||
respond(socket, 200, Journal.json("deleted", deleted));
|
||||
}
|
||||
break;
|
||||
default: respond(socket, 404, Journal.json("error", "Unknown route"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
journal.event("api.request_failed", e.getClass().getSimpleName());
|
||||
respond(socket, e instanceof IllegalStateException ? 409 : 400,
|
||||
Journal.json("error", "Invalid request", "kind", e.getClass().getSimpleName()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Never put request bodies, license values or bearer tokens in logcat.
|
||||
journal.event("api.request_failed", e.getClass().getSimpleName());
|
||||
} finally { sockets.remove(socket); }
|
||||
}
|
||||
private void respond(Socket socket, int status, JSONObject value) throws IOException {
|
||||
byte[] body = value.toString().getBytes(StandardCharsets.UTF_8);
|
||||
String head = "HTTP/1.1 " + status + " Response\r\nContent-Type: application/json; charset=utf-8\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: " + body.length + "\r\n\r\n";
|
||||
OutputStream out = socket.getOutputStream(); out.write(head.getBytes(StandardCharsets.US_ASCII)); out.write(body); out.flush();
|
||||
}
|
||||
@Override public void close() throws IOException {
|
||||
closed = true; server.close();
|
||||
for (Socket socket : sockets) try { socket.close(); } catch (IOException ignored) {}
|
||||
clients.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.sydxky.rtos.bridge;
|
||||
|
||||
import android.app.*;
|
||||
import android.content.*;
|
||||
import android.graphics.Color;
|
||||
import android.os.*;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
import org.json.*;
|
||||
import java.io.*;
|
||||
|
||||
public final class MainActivity extends Activity {
|
||||
private BridgeService bridge;
|
||||
private boolean bound;
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private TextView status, events;
|
||||
private EditText app, license, pkg;
|
||||
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; }
|
||||
};
|
||||
private final Runnable tick = new Runnable() { public void run() { refresh(); handler.postDelayed(this, 1500); } };
|
||||
@Override public void onCreate(Bundle saved) {
|
||||
super.onCreate(saved);
|
||||
getWindow().setStatusBarColor(Color.rgb(19, 40, 63));
|
||||
ScrollView scroll = new ScrollView(this);
|
||||
LinearLayout root = new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); root.setPadding(dp(22), dp(24), dp(22), dp(24));
|
||||
scroll.addView(root); setContentView(scroll);
|
||||
TextView title = label("RTOS 真机调试桥", 26); root.addView(title);
|
||||
root.addView(label("电脑 USB ADB → P30PRO → 已配对 S2\n请保持运动健康与手表连接。", 15));
|
||||
status = label("正在启动…", 14); root.addView(status);
|
||||
app = field("互联 appId", "test"); root.addView(app);
|
||||
license = field("互联 license(按厂商要求填写)", "test");
|
||||
license.setInputType(129); root.addView(license);
|
||||
button(root, "连接运动健康", () -> { if (bridge != null) bridge.engine.connect(Journal.json("appId", app.getText().toString().trim(), "license", license.getText().toString())); });
|
||||
button(root, "获取手表信息", () -> command(Journal.json("command", "info")));
|
||||
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, "开启手表日志", () -> 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)));
|
||||
button(root, "断开互联服务", () -> { if (bridge != null) bridge.engine.disconnect(); });
|
||||
button(root, "停止调试桥", () -> { unbind(); stopService(new Intent(this, BridgeService.class)); status.setText("已停止。重新打开应用可启动。"); });
|
||||
events = label("等待操作", 12); events.setTextIsSelectable(true); root.addView(events);
|
||||
startForegroundService(new Intent(this, BridgeService.class));
|
||||
bound = bindService(new Intent(this, BridgeService.class), connection, BIND_AUTO_CREATE);
|
||||
if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED)
|
||||
requestPermissions(new String[]{android.Manifest.permission.POST_NOTIFICATIONS}, 11);
|
||||
}
|
||||
private void command(JSONObject request) {
|
||||
if (bridge == null) return;
|
||||
try { bridge.engine.execute(request); } catch (Exception e) { Toast.makeText(this, e.toString(), Toast.LENGTH_LONG).show(); }
|
||||
}
|
||||
@Override protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
startForegroundService(new Intent(this, BridgeService.class));
|
||||
if (!bound) bound = bindService(new Intent(this, BridgeService.class), connection, BIND_AUTO_CREATE);
|
||||
}
|
||||
private void refresh() {
|
||||
if (bridge == null) return;
|
||||
JSONObject s = bridge.engine.status();
|
||||
status.setText("服务:" + s.optString("state") + " · 协议 " + s.optInt("protocol") + "\n电脑转发端口:18765");
|
||||
JSONArray rows = bridge.journal.events(0);
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (int i = Math.max(0, rows.length() - 18); i < rows.length(); i++) {
|
||||
JSONObject row = rows.optJSONObject(i);
|
||||
String data = String.valueOf(row.opt("data"));
|
||||
text.append(row.optLong("seq")).append(" ").append(row.optString("type")).append("\n")
|
||||
.append(data.substring(0, Math.min(1200, data.length()))).append("\n\n");
|
||||
}
|
||||
events.setText(text.toString());
|
||||
}
|
||||
@Override protected void onActivityResult(int request, int result, Intent data) {
|
||||
super.onActivityResult(request, result, data);
|
||||
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");
|
||||
try {
|
||||
if (!inbox.isDirectory() && !inbox.mkdirs()) throw new IOException("Cannot create inbox");
|
||||
try (InputStream in = getContentResolver().openInputStream(uri); OutputStream out = new FileOutputStream(temporary)) {
|
||||
byte[] buffer = new byte[16384]; int n, total = 0;
|
||||
if (in == null) throw new IOException("Cannot read document");
|
||||
while ((n = in.read(buffer)) != -1) { total += n; if (total > LocalApi.MAX_FILE) throw new IOException("包超过 32 MiB"); out.write(buffer, 0, n); }
|
||||
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")));
|
||||
} catch (Exception e) { handler.post(() -> Toast.makeText(this, e.toString(), Toast.LENGTH_LONG).show()); }
|
||||
finally { if (temporary.exists()) temporary.delete(); }
|
||||
}, "document-import").start();
|
||||
}
|
||||
private int dp(int n) { return (int) (n * getResources().getDisplayMetrics().density); }
|
||||
private TextView label(String text, int size) { TextView v = new TextView(this); v.setText(text); v.setTextSize(size); v.setPadding(0, dp(8), 0, dp(8)); return v; }
|
||||
private EditText field(String hint, String value) { EditText v = new EditText(this); v.setHint(hint); v.setText(value); v.setTextSize(15); v.setSingleLine(true); return v; }
|
||||
private void button(LinearLayout parent, String text, Runnable action) { Button b = new Button(this); b.setText(text); b.setAllCaps(false); b.setOnClickListener(v -> action.run()); parent.addView(b); }
|
||||
private void unbind() { if (bound) { unbindService(connection); bound = false; } bridge = null; }
|
||||
@Override protected void onResume() { super.onResume(); handler.post(tick); }
|
||||
@Override protected void onPause() { handler.removeCallbacks(tick); super.onPause(); }
|
||||
@Override protected void onDestroy() { unbind(); super.onDestroy(); }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.sydxky.rtos.bridge;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.graphics.*;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.*;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Watch screenshot entry, private import and visual safe-area inspection. */
|
||||
public final class UiToolsActivity extends Activity {
|
||||
private TextView details;
|
||||
private CaptureView preview;
|
||||
private Button select;
|
||||
private volatile boolean closed;
|
||||
private static final Object CAPTURE_LOCK = new Object();
|
||||
|
||||
@Override public void onCreate(Bundle saved) {
|
||||
super.onCreate(saved);
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
int padding = (int) (18 * getResources().getDisplayMetrics().density);
|
||||
root.setPadding(padding, padding, padding, padding);
|
||||
ScrollView scroll = new ScrollView(this); scroll.addView(root); setContentView(scroll);
|
||||
TextView title = new TextView(this); title.setText("S2 截图与 UI 检查"); title.setTextSize(24); root.addView(title);
|
||||
TextView help = new TextView(this);
|
||||
help.setText("手表截屏:运动健康 → 设备 → 更多功能 → 手表截屏。\n选择生成的图片后可预览、检查圆屏安全区,并由电脑导出。\n\n控件树、点击和滑动:电脑 ui.py 支持 ADB 手机界面,以及已连接的 Glyphix Inspector。当前 Wear Engine 接口没有确认通用 S2 触摸和控件树通道。");
|
||||
help.setTextSize(15); root.addView(help);
|
||||
button(root, "打开运动健康截屏入口", () -> {
|
||||
Intent launch = getPackageManager().getLaunchIntentForPackage("com.heytap.health");
|
||||
if (launch == null) Toast.makeText(this, "请先安装并配对运动健康", Toast.LENGTH_LONG).show();
|
||||
else {
|
||||
try { startActivity(launch); }
|
||||
catch (RuntimeException error) { Toast.makeText(this, "运动健康暂不可打开", Toast.LENGTH_LONG).show(); }
|
||||
}
|
||||
});
|
||||
select = button(root, "选择官方生成的手表截屏", () -> startActivityForResult(
|
||||
new Intent(Intent.ACTION_OPEN_DOCUMENT).setType("image/*").addCategory(Intent.CATEGORY_OPENABLE), 20));
|
||||
CheckBox safe = new CheckBox(this); safe.setText("显示圆屏 5% 安全区参考线"); safe.setChecked(true); root.addView(safe);
|
||||
preview = new CaptureView();
|
||||
root.addView(preview, new LinearLayout.LayoutParams(-1, (int) (320 * getResources().getDisplayMetrics().density)));
|
||||
safe.setOnCheckedChangeListener((button, checked) -> { preview.safeArea = checked; preview.invalidate(); });
|
||||
details = new TextView(this); details.setTextSize(13); details.setTextIsSelectable(true); root.addView(details);
|
||||
button(root, "返回调试桥", this::finish);
|
||||
loadExisting();
|
||||
}
|
||||
|
||||
private Button button(LinearLayout parent, String text, Runnable action) {
|
||||
Button b = new Button(this); b.setText(text); b.setAllCaps(false); b.setOnClickListener(v -> action.run()); parent.addView(b);
|
||||
return b;
|
||||
}
|
||||
private void loadExisting() {
|
||||
File image = new File(getFilesDir(), "ui/watch-capture.png");
|
||||
File meta = new File(getFilesDir(), "ui/watch-capture.json");
|
||||
if (image.isFile() && meta.isFile()) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Bitmap bitmap;
|
||||
String text;
|
||||
synchronized (CAPTURE_LOCK) {
|
||||
bitmap = BitmapFactory.decodeFile(image.toString());
|
||||
text = new String(Files.readAllBytes(meta.toPath()), StandardCharsets.UTF_8);
|
||||
}
|
||||
runOnUiThread(() -> { if (!closed) { preview.setBitmap(bitmap); details.setText(text); } else if (bitmap != null) bitmap.recycle(); });
|
||||
} catch (IOException ignored) { }
|
||||
}, "capture-preview").start();
|
||||
} else details.setText("尚未选择图片。选择的图片由用户确认来源,不自动作为 S2 实机验收证据。");
|
||||
}
|
||||
@Override protected void onActivityResult(int request, int result, Intent data) {
|
||||
super.onActivityResult(request, result, data);
|
||||
if (request != 20 || result != RESULT_OK || data == null || data.getData() == null) return;
|
||||
android.net.Uri source = data.getData();
|
||||
select.setEnabled(false);
|
||||
details.setText("正在检查并保存截屏…");
|
||||
new Thread(() -> {
|
||||
File temp = null;
|
||||
Bitmap bitmap = null;
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (InputStream in = getContentResolver().openInputStream(source)) {
|
||||
if (in == null) throw new IOException("无法读取图片");
|
||||
byte[] block = new byte[16384]; int n, total = 0;
|
||||
while ((n = in.read(block)) != -1) {
|
||||
total += n; if (total > 8 * 1024 * 1024) throw new IOException("图片超过 8 MiB");
|
||||
bytes.write(block, 0, n);
|
||||
}
|
||||
}
|
||||
byte[] encoded = bytes.toByteArray();
|
||||
BitmapFactory.Options bounds = new BitmapFactory.Options(); bounds.inJustDecodeBounds = true;
|
||||
BitmapFactory.decodeByteArray(encoded, 0, encoded.length, bounds);
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0 || bounds.outWidth > 2048 || bounds.outHeight > 2048)
|
||||
throw new IOException("请选择不超过 2048×2048 的手表截屏");
|
||||
bitmap = BitmapFactory.decodeByteArray(encoded, 0, encoded.length);
|
||||
if (bitmap == null) throw new IOException("图片解码失败");
|
||||
File folder = new File(getFilesDir(), "ui");
|
||||
if (!folder.isDirectory() && !folder.mkdirs()) throw new IOException("无法创建截屏目录");
|
||||
temp = File.createTempFile("capture-", ".png", folder);
|
||||
try (OutputStream out = new FileOutputStream(temp)) {
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) throw new IOException("PNG 保存失败");
|
||||
}
|
||||
byte[] hash = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(temp.toPath()));
|
||||
StringBuilder sha = new StringBuilder(); for (byte b : hash) sha.append(String.format(Locale.ROOT, "%02x", b & 255));
|
||||
String metadata = Journal.json("source", "user_selected_image_origin_not_attested", "width", bitmap.getWidth(),
|
||||
"height", bitmap.getHeight(), "sha256", sha.toString(), "importedAt", System.currentTimeMillis()).toString();
|
||||
synchronized (CAPTURE_LOCK) {
|
||||
Files.move(temp.toPath(), new File(folder, "watch-capture.png").toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.write(new File(folder, "watch-capture.json").toPath(), metadata.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
Bitmap resultBitmap = bitmap; bitmap = null;
|
||||
runOnUiThread(() -> {
|
||||
if (closed) { resultBitmap.recycle(); return; }
|
||||
preview.setBitmap(resultBitmap);
|
||||
details.setText("图片已保存在本应用私有目录。安全区参考线不会写入原图。\n" + metadata +
|
||||
"\n\n电脑导出:python ui.py --target watch-import --serial <手机序列号> screenshot --output watch.png");
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> { if (!closed) details.setText("导入失败:" + error.getClass().getSimpleName()); });
|
||||
} finally {
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
if (temp != null && temp.exists()) temp.delete();
|
||||
runOnUiThread(() -> { if (!closed) select.setEnabled(true); });
|
||||
}
|
||||
}, "watch-capture-import").start();
|
||||
}
|
||||
@Override protected void onDestroy() { closed = true; preview.setBitmap(null); super.onDestroy(); }
|
||||
private final class CaptureView extends View {
|
||||
Bitmap bitmap;
|
||||
boolean safeArea = true;
|
||||
final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
|
||||
CaptureView() { super(UiToolsActivity.this); setBackgroundColor(Color.rgb(24, 35, 49)); }
|
||||
void setBitmap(Bitmap image) { if (bitmap != null && bitmap != image) bitmap.recycle(); bitmap = image; invalidate(); }
|
||||
@Override protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
if (bitmap == null) return;
|
||||
float scale = Math.min((float)getWidth() / bitmap.getWidth(), (float)getHeight() / bitmap.getHeight());
|
||||
float w = bitmap.getWidth() * scale, h = bitmap.getHeight() * scale;
|
||||
RectF destination = new RectF((getWidth()-w)/2, (getHeight()-h)/2, (getWidth()+w)/2, (getHeight()+h)/2);
|
||||
paint.setStyle(Paint.Style.FILL); canvas.drawBitmap(bitmap, null, destination, paint);
|
||||
if (safeArea) {
|
||||
paint.setColor(Color.CYAN); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(2);
|
||||
canvas.drawCircle(getWidth()/2f, getHeight()/2f, Math.min(w, h) * .45f, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.sydxky.rtos.bridge;
|
||||
|
||||
import android.content.*;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.os.*;
|
||||
import org.json.*;
|
||||
import java.io.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Small independent implementation of the observed Wear Engine Binder wire contract. */
|
||||
final class WearEngine {
|
||||
static final String ACTION = "com.xfaith.glyphix.WEAR_ENGINE_ACTION";
|
||||
static final String OLD = "com.glyphix.mas.GlyphixWear";
|
||||
static final String NEW = "com.glyphix.mas.GlyphixWear263";
|
||||
private final Context context;
|
||||
private final Journal journal;
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
private final Map<String, Binder> callbacks = new ConcurrentHashMap<>();
|
||||
private volatile IBinder remote;
|
||||
private volatile String descriptor = OLD, appId = "test", servicePackage = "", state = "disconnected";
|
||||
private volatile int version, generation;
|
||||
private ServiceConnection connection;
|
||||
private Binder heartbeat, rpc, messages, link, auth;
|
||||
private volatile boolean authorized;
|
||||
private volatile boolean closing;
|
||||
|
||||
WearEngine(Context context, Journal journal) { this.context = context; this.journal = journal; }
|
||||
interface Writer { void write(Parcel p) throws Exception; }
|
||||
interface Reader<T> { T read(Parcel p) throws Exception; }
|
||||
private <T> T call(IBinder binder, String token, int code, Writer writer, Reader<T> reader) throws Exception {
|
||||
if (binder == null || !binder.isBinderAlive()) throw new IllegalStateException("Wear Engine is disconnected");
|
||||
Parcel data = Parcel.obtain(), reply = Parcel.obtain();
|
||||
try {
|
||||
data.writeInterfaceToken(token);
|
||||
writer.write(data);
|
||||
if (!binder.transact(code, data, reply, 0)) throw new IOException("Unsupported Binder transaction " + code);
|
||||
reply.readException();
|
||||
return reader.read(reply);
|
||||
} finally { data.recycle(); reply.recycle(); }
|
||||
}
|
||||
private void transact(int code, Writer writer) throws Exception { call(remote, descriptor, code, writer, p -> null); }
|
||||
private boolean modern() { return version >= 2006003; }
|
||||
JSONArray services() {
|
||||
JSONArray out = new JSONArray();
|
||||
for (ResolveInfo r : context.getPackageManager().queryIntentServices(new Intent(ACTION), 0))
|
||||
out.put(Journal.json("package", r.serviceInfo.packageName, "class", r.serviceInfo.name,
|
||||
"exported", r.serviceInfo.exported));
|
||||
return out;
|
||||
}
|
||||
JSONObject status() {
|
||||
return Journal.json("state", state, "service", servicePackage, "masVersion", version,
|
||||
"protocol", modern() ? 263 : 262, "appId", appId, "messageAuthorized", authorized, "services", services());
|
||||
}
|
||||
void connect(JSONObject args) {
|
||||
if (closing) throw new IllegalStateException("Bridge is stopping");
|
||||
String requested = args.optString("service", "com.heytap.health");
|
||||
String requestedApp = args.optString("appId", "test");
|
||||
String license = args.optString("license", "test");
|
||||
main.post(() -> {
|
||||
disconnectNow();
|
||||
appId = requestedApp;
|
||||
int epoch = ++generation;
|
||||
List<ResolveInfo> found = context.getPackageManager().queryIntentServices(new Intent(ACTION), 0);
|
||||
ResolveInfo selected = null;
|
||||
for (ResolveInfo r : found) if (requested.equals(r.serviceInfo.packageName)) { selected = r; break; }
|
||||
if (selected == null && requested.isEmpty() && found.size() == 1) selected = found.get(0);
|
||||
if (selected == null) { state = "service_not_found"; journal.event("connect.failed", status()); return; }
|
||||
servicePackage = selected.serviceInfo.packageName;
|
||||
Intent intent = new Intent(ACTION).setComponent(new ComponentName(servicePackage, selected.serviceInfo.name));
|
||||
intent.putExtra("packageName", context.getPackageName());
|
||||
intent.putExtra("appId", appId);
|
||||
intent.putExtra("license", license);
|
||||
state = "binding";
|
||||
connection = new ServiceConnection() {
|
||||
@Override public void onServiceConnected(ComponentName name, IBinder binder) {
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
int detected = call(binder, OLD, 14, p -> {}, Parcel::readInt);
|
||||
IBinder target = detected >= 2006003 ? call(binder, OLD, 18, p -> p.writeInt(263), Parcel::readStrongBinder) : binder;
|
||||
if (epoch != generation) return;
|
||||
version = detected; remote = target; descriptor = modern() ? NEW : OLD;
|
||||
if (target == null) throw new IOException("263 Binder is null");
|
||||
if (!descriptor.equals(target.getInterfaceDescriptor())) throw new IOException("Unexpected interface descriptor");
|
||||
heartbeat = callback("WearEngineHearBeatResolver", (code, data) -> {});
|
||||
rpc = callback("WearEngineMessageResolver", (code, data) -> journal.event("watch.log", data.readString()));
|
||||
messages = callback("WearEngineMessageResolver", (code, data) -> journal.event("watch.message", data.readString()));
|
||||
link = callback("WearEngineDeviceLinkStatusResolver", (code, data) -> journal.event("watch.link", data.readInt() != 0));
|
||||
transact(modern() ? 11 : 8, p -> { if (modern()) { p.writeString(context.getPackageName()); p.writeString(appId); } p.writeStrongBinder(heartbeat); });
|
||||
transact(modern() ? 3 : 16, p -> p.writeStrongBinder(rpc));
|
||||
transact(modern() ? 5 : 2, p -> { if (modern()) p.writeString(context.getPackageName()); p.writeStrongBinder(link); });
|
||||
transact(modern() ? 13 : 10, p -> { if (modern()) { p.writeString(context.getPackageName()); p.writeString("wear-engine-" + context.getPackageName()); } p.writeStrongBinder(messages); });
|
||||
if (modern()) {
|
||||
auth = callback("WearEngineMessageResolver", (code, data) -> {
|
||||
JSONObject payload = new JSONObject(data.readString());
|
||||
authorized = license.equals(payload.optString("fingerprint"));
|
||||
journal.event("watch.auth", authorized);
|
||||
if (authorized) worker.execute(() -> {
|
||||
if (epoch != generation) return;
|
||||
try { transact(10, p -> {
|
||||
p.writeString(context.getPackageName()); p.writeString(appId);
|
||||
p.writeString("{\"event\":\"OnOpen\",\"data\":{}}"); p.writeStrongBinder(null);
|
||||
}); } catch (Exception e) { journal.event("auth.notify_failed", e.getClass().getSimpleName()); }
|
||||
});
|
||||
});
|
||||
transact(17, p -> { p.writeString(context.getPackageName()); p.writeString("wear-engine-auth-" + context.getPackageName()); p.writeStrongBinder(auth); });
|
||||
}
|
||||
state = "bound";
|
||||
journal.event("connect.bound", status());
|
||||
} catch (Exception e) {
|
||||
if (epoch == generation) { state = "failed"; remote = null; journal.event("connect.failed", e.toString()); }
|
||||
}
|
||||
});
|
||||
}
|
||||
@Override public void onServiceDisconnected(ComponentName name) { lost(epoch, "service_disconnected"); }
|
||||
@Override public void onNullBinding(ComponentName name) { lost(epoch, "null_binding"); }
|
||||
@Override public void onBindingDied(ComponentName name) { lost(epoch, "binding_died"); }
|
||||
};
|
||||
try {
|
||||
if (!context.bindService(intent, connection, Context.BIND_AUTO_CREATE)) { state = "bind_rejected"; connection = null; }
|
||||
} catch (Exception e) { state = "failed"; journal.event("connect.failed", e.toString()); }
|
||||
main.postDelayed(() -> { if (epoch == generation && "binding".equals(state)) { state = "bind_timeout"; journal.event("connect.failed", "No service callback within 15s"); } }, 15000);
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
interface Callback { void accept(int code, Parcel data) throws Exception; }
|
||||
private Binder callback(String name, Callback body) {
|
||||
String token = "com.glyphix.mas." + name;
|
||||
int epoch = generation;
|
||||
return new Binder() {
|
||||
@Override protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException {
|
||||
if (code == INTERFACE_TRANSACTION) { if (reply != null) reply.writeString(token); return true; }
|
||||
if (code >= 1 && code <= 3) {
|
||||
data.enforceInterface(token);
|
||||
if (epoch == generation) {
|
||||
try { body.accept(code, data); }
|
||||
catch (Exception e) { journal.event("callback.error", e.getClass().getSimpleName()); }
|
||||
}
|
||||
if (reply != null) reply.writeNoException();
|
||||
return true;
|
||||
}
|
||||
return super.onTransact(code, data, reply, flags);
|
||||
}
|
||||
};
|
||||
}
|
||||
String execute(JSONObject args) {
|
||||
String command = args.optString("command");
|
||||
if (!Arrays.asList("info", "link", "check", "launch", "install", "message", "send-file", "rpc", "logs").contains(command))
|
||||
throw new IllegalArgumentException("Unknown command");
|
||||
String id = journal.newJob(command);
|
||||
int epoch = generation;
|
||||
Binder resolver = callback("WearEngineProgressResolver", (code, data) -> {
|
||||
if (!journal.pending(id)) return;
|
||||
String raw = data.readString();
|
||||
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);
|
||||
});
|
||||
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");
|
||||
}, 180000);
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
if (epoch != generation) throw new IOException("Connection changed");
|
||||
if (!"bound".equals(state)) throw new IOException("Bind the service first");
|
||||
switch (command) {
|
||||
case "link":
|
||||
boolean connected = call(remote, descriptor, 1, p -> {}, p -> p.readInt() != 0);
|
||||
callbacks.remove(id); journal.result(id, "success", Journal.json("connected", connected)); break;
|
||||
case "info": transact(modern() ? 7 : 4, p -> { if (modern()) p.writeString(context.getPackageName()); p.writeStrongBinder(resolver); }); break;
|
||||
case "check": case "launch": {
|
||||
String pkg = args.getString("package");
|
||||
if (!pkg.matches("[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)+")) throw new IllegalArgumentException("Invalid package name");
|
||||
int code = command.equals("check") ? (modern() ? 8 : 5) : (modern() ? 9 : 6);
|
||||
transact(code, p -> { if (modern()) p.writeString(context.getPackageName()); p.writeString(pkg); p.writeStrongBinder(resolver); }); break;
|
||||
}
|
||||
case "install": case "send-file": {
|
||||
if (command.equals("send-file") && modern() && !authorized)
|
||||
throw new IllegalStateException("Watch interconnect authorization pending; use matching appId/license");
|
||||
String name = args.getString("file");
|
||||
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");
|
||||
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 -> {
|
||||
p.writeInt(1); fd.writeToParcel(p, 0); p.writeString(name); p.writeString(args.getString("remote")); p.writeString(appId); p.writeStrongBinder(resolver);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "message":
|
||||
if (modern() && !authorized) throw new IllegalStateException("Watch interconnect authorization pending; use matching appId/license");
|
||||
transact(modern() ? 10 : 7, p -> {
|
||||
if (modern()) p.writeString(context.getPackageName());
|
||||
p.writeString(appId);
|
||||
p.writeString(args.getString("message")); p.writeStrongBinder(resolver);
|
||||
}); break;
|
||||
case "rpc": case "logs": {
|
||||
String method = command.equals("logs") ? "toggle_rpc_log_feature" : args.getString("method");
|
||||
String params = command.equals("logs") ? Journal.json("status", args.optBoolean("enabled", true) ? 1 : 0).toString() : args.optString("params", "{}");
|
||||
transact(modern() ? 4 : 17, p -> { p.writeString(method); p.writeString(params); p.writeStrongBinder(resolver); }); break;
|
||||
}
|
||||
default: throw new IllegalArgumentException(command);
|
||||
}
|
||||
} catch (Exception e) { callbacks.remove(id); journal.result(id, "failed", e.toString()); }
|
||||
});
|
||||
return id;
|
||||
}
|
||||
void disconnect() { main.post(this::disconnectNow); }
|
||||
private void disconnectNow() {
|
||||
++generation;
|
||||
IBinder old = remote;
|
||||
String oldDescriptor = descriptor, oldApp = appId;
|
||||
boolean wasModern = modern();
|
||||
if (old != null && !worker.isShutdown()) worker.execute(() -> {
|
||||
try {
|
||||
call(old, oldDescriptor, wasModern ? 12 : 9, p -> { if (wasModern) { p.writeString(context.getPackageName()); p.writeString(oldApp); } }, p -> null);
|
||||
call(old, oldDescriptor, wasModern ? 14 : 11, p -> { if (wasModern) { p.writeString(context.getPackageName()); p.writeString("wear-engine-" + context.getPackageName()); } }, p -> null);
|
||||
call(old, oldDescriptor, wasModern ? 6 : 3, p -> { if (wasModern) p.writeString(context.getPackageName()); }, p -> null);
|
||||
// registerRpcLogListener does not accept null in the observed service.
|
||||
// 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");
|
||||
if (connection != null) { try { context.unbindService(connection); } catch (IllegalArgumentException ignored) {} connection = null; }
|
||||
heartbeat = rpc = messages = link = auth = null; authorized = false; state = "disconnected";
|
||||
}
|
||||
void close() { closing = true; disconnectNow(); main.removeCallbacksAndMessages(null); worker.shutdown(); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
|
||||
<path android:fillColor="#13283F" android:pathData="M0,0h48v48h-48z" />
|
||||
<path android:fillColor="#83DDD4" android:pathData="M7,10h14v28h-14zM29,15h13v18h-13z" />
|
||||
<path android:fillColor="#13283F" android:pathData="M10,14h8v18h-8zM32,18h7v12h-7z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M21,22h8v4h-8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<data-extraction-rules>
|
||||
<cloud-backup><exclude domain="root" path="." /></cloud-backup>
|
||||
<device-transfer><exclude domain="root" path="." /></device-transfer>
|
||||
</data-extraction-rules>
|
||||
在新工单中引用
屏蔽一个用户