feat(rtos): 首次发布手机真机调试桥与S2探针

这个提交包含在:
sydxky 于 2026-11-14 13:25:37 +08:00 提交
当前提交 2f0f4b9702
59 个文件已更改
+4303

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

+17
查看文件
@@ -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>