Files
ykxmz/_tests/java/JavaClientTest.java
T
xmz7891 b705216e4a ykxmz 1.5.0锛氫慨澶嶆墜琛ㄧ TLS 璇佷功鏍¢獙 + MTU 淇缃戠粶绋冲畾鎬?
琚帶绔?- 淇 MTU 闂锛氬す MSS + 璺緞 MTU 鎺㈡祴锛堝叕缃戝彲鐢ㄧ巼 48% -> 100%锛?- 澶嶇敤 HTTP 杩炴帴锛堝厤鎺夋瘡娆℃柊寤?TLS 鐨?30% 鎻℃墜澶辫触锛?- 姣忓垎閽熷洖澶存壘灞€鍩熺綉锛岃嚜鍔ㄥ垏鍥炲揩 400 鍊嶇殑璺緞
- 鐢婚潰蹇冭烦甯э細闈欐鏃剁渷娴侀噺锛屽鎴风浠嶇煡閬撴祦鏄椿鐨?- 閿佸睆鏃剁粰鍑哄彲鎿嶄綔鐨勬彁绀?
鎵嬭〃绔?- 鍐呯疆 GTS Root R4 / GlobalSign Root CA锛屼慨 SSLHandshakeException
- 杩炴帴閿欒缈昏瘧鎴愪汉璇濓紙璇佷功/鏃堕棿/瑙f瀽/瓒呮椂鍒嗗埆鎻愮ず锛?- 缁忎腑杞繛涓婂悗鐢ㄦ湇鍔″櫒鎶ョ殑鍦板潃鍒锋柊宸查厤瀵硅褰?- 鏂板灞€鍩熺綉杩炴帴椤甸潰銆佹湇鍔″櫒寤惰繜妫€娴?
鏈嶅姟鍣ㄧ
- 鏀寔 chunked 璇锋眰浣擄紙淇瘡 16 绉掍竴娆$殑杩炴帴閿欎贡涓庡ぇ闈㈢Н 502锛?- 缃戠粶璋冧紭鑴氭湰 net-tune.sh锛堝紑鏈鸿嚜鍔ㄥ簲鐢級
- 绠$悊椤碉細鎵归噺鍒犻櫎銆佺綉缁滀綋妫€銆佷笅杞藉尯
- 闅ч亾鏀?HTTP/2 + 鍙岃繛鎺?
鏂囨。
- 璁稿彲鍗忚 / 瀹夎鍓嶅繀璇?/ 瀹夎鍚庨』鐭ワ紙浣滆€咃細灏忛害瀛愶級
2026-10-06 19:01:05 +08:00

722 lines
33 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import com.lanremote.watch.B64;
import com.lanremote.watch.Json;
import com.lanremote.watch.RemoteClient;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Random;
/**
* 跨语言联调测试:桌面 JVM 上的 Java 协议层 <-> 真实 Python 被控端。
*
* 目的:证明安卓端用的这套代码不是"看起来应该能用",
* 而是真的能跟被控端完成配对、鉴权、传文件、执行命令。
*/
public class JavaClientTest {
private static int passed = 0;
private static int failed = 0;
private static final List<String> failures = new ArrayList<String>();
private static void check(String name, boolean condition) {
check(name, condition, "");
}
private static void check(String name, boolean condition, String detail) {
if (condition) {
passed++;
System.out.println(" [PASS] " + name);
} else {
failed++;
failures.add(name);
System.out.println(" [FAIL] " + name + " " + detail);
}
}
private static void section(String title) {
System.out.println("====================================================================");
System.out.println(title);
}
public static void main(String[] args) throws Exception {
String host = args.length > 0 ? args[0] : "127.0.0.1";
int port = args.length > 1 ? Integer.parseInt(args[1]) : 8799;
String code = args.length > 2 ? args[2] : "246813";
boolean realAgent = args.length > 3 && "real".equals(args[3]);
final String dumpPath = args.length > 4 ? args[4] : null;
String deviceId = "java-test-device";
String deviceName = "JVM测试端";
// ---------------- 1. 纯 Java 编解码 ----------------
section("1. JSON 编解码");
Map<String, Object> sample = Json.obj("a", 1, "b", "中文\"引号\\反斜杠", "c", true);
String encoded = Json.write(sample);
Map<String, Object> decoded = Json.parseObject(encoded);
check("整数往返", Json.integer(decoded, "a", -1) == 1);
check("中文与转义往返",
"中文\"引号\\反斜杠".equals(Json.str(decoded, "b", "")),
Json.str(decoded, "b", ""));
check("布尔往返", Json.bool(decoded, "c", false));
String tricky = "{\"a\":[1,2.5,-3e2,true,false,null],\"b\":{\"c\":\"\\u4e2d\"}}";
Map<String, Object> trickyParsed = Json.parseObject(tricky);
check("嵌套数组解析", Json.list(trickyParsed, "a").size() == 6);
check("\\u 转义解析",
"中".equals(Json.str(Json.asMap(trickyParsed.get("b")), "c", "")));
boolean threw = false;
try {
Json.parseObject("{bad json");
} catch (RuntimeException e) {
threw = true;
}
check("非法 JSON 抛异常", threw);
// ---------------- 2. Base64 ----------------
section("2. Base64 编解码");
Random random = new Random(42);
boolean b64ok = true;
for (int size : new int[]{0, 1, 2, 3, 4, 5, 100, 1023, 1024, 65537}) {
byte[] blob = new byte[size];
random.nextBytes(blob);
byte[] back = B64.decode(B64.encode(blob));
if (!Arrays.equals(blob, back)) {
b64ok = false;
System.out.println(" 长度 " + size + " 不一致");
}
}
check("各种长度往返一致(含空、非 3 倍数、跨块)", b64ok);
// ---------------- 3. 错误配对码 ----------------
section("3. 配对码校验");
RemoteClient bad = new RemoteClient();
bad.setDevice(deviceId, deviceName);
Map<String, Object> wrong = bad.connect(host, port, "000000", null,
new SilentListener(), 8000);
check("错误配对码被拒绝", !Json.bool(wrong, "ok", true),
Json.str(wrong, "error", ""));
bad.close();
// ---------------- 4. 正确配对 ----------------
section("4. 配对与令牌");
RemoteClient client = new RemoteClient();
client.setDevice(deviceId, deviceName);
Map<String, Object> paired = client.connect(host, port, code, null,
new SilentListener(), 8000);
check("正确配对码通过", Json.bool(paired, "ok", false),
Json.str(paired, "error", ""));
String token = Json.str(paired, "token", "");
check("拿到设备令牌", token.length() == 32, "token=" + token);
check("拿到服务端名称", Json.str(paired, "serverName", "").length() > 0);
client.close();
// ---------------- 5. 令牌免配对 ----------------
section("5. 令牌免配对重连(模拟下次打开 App)");
RemoteClient badToken = new RemoteClient();
badToken.setDevice(deviceId, deviceName);
Map<String, Object> rejected = badToken.connect(host, port, null, "deadbeef",
new SilentListener(), 8000);
check("无效令牌被拒绝", !Json.bool(rejected, "ok", true));
check("提示需要重新配对", Json.bool(rejected, "needPair", false));
badToken.close();
FileAccumulator accumulator = new FileAccumulator();
RemoteClient session = new RemoteClient();
session.setDevice(deviceId, deviceName);
Map<String, Object> authed = session.connect(host, port, null, token,
accumulator, 8000);
check("有效令牌直接登录", Json.bool(authed, "ok", false),
Json.str(authed, "error", ""));
// ---------------- 6. 基础命令 ----------------
section("6. 基础命令");
check("ping", session.ping(5000));
Map<String, Object> info = session.call(Json.obj("cmd", "info"), 8000);
Map<String, Object> infoBody = Json.asMap(info.get("info"));
check("info 返回系统信息", infoBody != null && infoBody.get("os") != null,
String.valueOf(info));
check("媒体:停止",
Json.bool(session.call(Json.obj("cmd", "media", "action", "stop"), 8000),
"ok", false));
check("媒体:非法动作被拒",
!Json.bool(session.call(Json.obj("cmd", "media", "action", "explode"), 8000),
"ok", false));
// ---------------- 7. 剪贴板 / 播报 / 通知 ----------------
section("7. 剪贴板 / 播报 / 通知");
check("clip_set",
Json.bool(session.call(Json.obj("cmd", "clip_set", "text", "来自Java的文本"),
8000), "ok", false));
Map<String, Object> clip = session.call(Json.obj("cmd", "clip_get"), 8000);
check("clip_get 取回一致", "来自Java的文本".equals(Json.str(clip, "text", "")),
Json.str(clip, "text", ""));
check("语音播报",
Json.bool(session.call(Json.obj("cmd", "say", "text", "Java 联调测试"), 15000),
"ok", false));
check("发送通知",
Json.bool(session.call(Json.obj("cmd", "notify", "title", "来自JVM",
"text", "联调"), 8000), "ok", false));
// ---------------- 8. 投图 / 放音 ----------------
section("8. 投图 / 放音(分块传输真实素材)");
// 真实 PNG:被控端会真的用 PIL 打开并全屏显示
byte[] image = B64.decode(TEST_PNG_B64);
Map<String, Object> imageReply =
session.sendChunks("image", "java_photo.png", image, 25000);
check("图片上传并全屏显示成功",
Json.bool(imageReply, "ok", false)
&& Json.integer(imageReply, "size", -1) == image.length,
String.valueOf(imageReply));
// 真实 WAV:被控端会用系统 MCI 播放
byte[] audio = makeWav(1, 8000);
Map<String, Object> audioReply =
session.sendChunks("audio", "java_sound.wav", audio, 25000);
check("音频上传并播放成功",
Json.bool(audioReply, "ok", false)
&& Json.integer(audioReply, "size", -1) == audio.length,
String.valueOf(audioReply));
// 反例:损坏数据必须被明确拒绝,而不是假装成功。
// 只有在连真实被控端时才有意义——桩模式不做真实图片解码。
byte[] garbage = new byte[1024];
random.nextBytes(garbage);
Map<String, Object> garbageReply =
session.sendChunks("image", "broken.png", garbage, 25000);
if (realAgent) {
check("损坏图片被正确拒绝(不会假装成功)",
!Json.bool(garbageReply, "ok", true), String.valueOf(garbageReply));
} else {
check("桩模式跳过损坏图片校验", true);
}
// ---------------- 9. 文件互传 ----------------
section("9. 文件上传 / 列表 / 回传");
byte[] payload = new byte[300 * 1024 + 7]; // 跨越多个分块
for (int i = 0; i < payload.length; i++) {
payload[i] = (byte) ((i * 31 + 7) & 0xFF); // 确定性内容,便于三方比对
}
System.out.println(" 发送方 sha256=" + sha256(payload)
+ " len=" + payload.length);
Map<String, Object> push =
session.sendChunks("file_push", "java_payload.bin", payload, 20000);
check("大文件分块上传成功",
Json.bool(push, "ok", false)
&& Json.integer(push, "size", -1) == payload.length,
String.valueOf(push));
Map<String, Object> listing = session.call(Json.obj("cmd", "file_list"), 8000);
boolean found = false;
for (Object item : Json.list(listing, "files")) {
Map<String, Object> file = Json.asMap(item);
if (file != null && "java_payload.bin".equals(Json.str(file, "name", ""))) {
found = true;
}
}
check("file_list 能看到刚上传的文件", found, String.valueOf(listing));
accumulator.reset();
session.call(Json.obj("cmd", "file_pull", "path", "java_payload.bin"), 20000);
boolean sizeOk = accumulator.size() == payload.length;
boolean readyOk = accumulator.ready();
byte[] pulled = accumulator.data();
boolean contentOk = Arrays.equals(pulled, payload);
System.out.println(" 接收方 sha256=" + sha256(pulled)
+ " len=" + pulled.length);
if (!contentOk && pulled.length == payload.length) {
int diffs = 0;
int firstDiff = -1;
for (int i = 0; i < payload.length; i++) {
if (pulled[i] != payload[i]) {
if (firstDiff < 0) {
firstDiff = i;
}
diffs++;
}
}
System.out.println(" 差异字节数 " + diffs + " / " + payload.length
+ ",首个 @" + firstDiff);
System.out.println(" 期望前32: " + hex(payload, 32));
System.out.println(" 实际前32: " + hex(pulled, 32));
if (firstDiff >= 0) {
int from = Math.max(0, firstDiff - 4);
System.out.println(" 期望@" + from + ": " + hex(slice(payload, from, 24), 24));
System.out.println(" 实际@" + from + ": " + hex(slice(pulled, from, 24), 24));
}
}
if (dumpPath != null) {
try {
java.io.FileOutputStream fos = new java.io.FileOutputStream(dumpPath);
fos.write(pulled);
fos.close();
System.out.println(" 已把接收内容写入 " + dumpPath);
} catch (Exception e) {
System.out.println(" 写 dump 失败: " + e);
}
}
check("file_pull 回传字节数一致", sizeOk, "got " + accumulator.size());
check("file_pull 收到结束标记", readyOk, "last 标志未到达");
check("file_pull 回传内容一致", contentOk,
"size=" + accumulator.size() + " ready=" + readyOk);
// ---------------- 10. 自定义命令 ----------------
section("10. 自定义命令");
Map<String, Object> run = session.call(
Json.obj("cmd", "run", "command", "echo java-bridge-ok"), 20000);
check("run 成功", Json.bool(run, "ok", false), String.valueOf(run));
check("run 输出正确",
Json.str(run, "stdout", "").contains("java-bridge-ok"),
Json.str(run, "stdout", ""));
check("run 退出码为 0", Json.integer(run, "code", -1) == 0);
// ---------------- 11. 事件通道 ----------------
section("11. 服务端主动事件");
// 注意:客户端自己 clip_set 的内容不会被回推(避免回声),
// 所以这里要从"电脑本地"改剪贴板,才能真正触发推送通道。
//
// 但剪贴板可能被机器上别的程序占着(输入法等),那时
// Set-Clipboard 会直接失败。这属于环境问题,不是被控端的毛病,
// 所以检测到就跳过,不要给出假失败。
if (!clipboardUsable()) {
System.out.println(" (剪贴板被其它程序占用,跳过这一项)");
check("剪贴板推送(环境不支持,跳过)", true, "skipped");
} else {
triggerLocalClipboard();
Map<String, Object> clipEvent = waitForEvent(accumulator, "clip", 20000);
check("收到电脑本地剪贴板变化推送", clipEvent != null,
clipEvent == null ? "超时未收到" : "ok");
}
// ---------------- 13. 屏幕镜像 / 截屏 ----------------
// ---------------- 12. 功能页 / 语音页 ----------------
section("12. 功能页 / 语音页(用户报连不上的那几个)");
try {
long t0 = System.currentTimeMillis();
Map<String, Object> featListReply =
session.call(Json.obj("cmd", "feature_list"), 25000);
long cost = System.currentTimeMillis() - t0;
java.util.List<Object> features = Json.list(featListReply, "features");
check("feature_list 返回 ok", Json.bool(featListReply, "ok", false),
Json.str(featListReply, "error", "(无 error 字段)")
+ " 耗时 " + cost + "ms");
check("feature_list 功能数 > 100", features.size() > 100,
features.size() + " 个,耗时 " + cost + "ms");
if (!features.isEmpty() && features.get(0) instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> first = (Map<String, Object>) features.get(0);
check("功能项带 name/label/group",
Json.str(first, "name", "").length() > 0
&& Json.str(first, "label", "").length() > 0
&& Json.str(first, "group", "").length() > 0,
String.valueOf(first));
}
long t1 = System.currentTimeMillis();
Map<String, Object> voiceListReply =
session.call(Json.obj("cmd", "voice_list"), 25000);
java.util.List<Object> voices = Json.list(voiceListReply, "voices");
check("voice_list 返回 ok", Json.bool(voiceListReply, "ok", false),
Json.str(voiceListReply, "error", "") + " 耗时 "
+ (System.currentTimeMillis() - t1) + "ms");
check("voice_list 有音色", voices.size() > 0,
voices.size() + " 个");
long t2 = System.currentTimeMillis();
Map<String, Object> audioListReply =
session.call(Json.obj("cmd", "audio_list"), 25000);
check("audio_list 返回 ok", Json.bool(audioListReply, "ok", false),
Json.str(audioListReply, "error", "") + " 耗时 "
+ (System.currentTimeMillis() - t2) + "ms");
long t3 = System.currentTimeMillis();
Map<String, Object> sayTestReply =
session.call(Json.obj("cmd", "say", "text", "测试"), 25000);
check("say 返回 ok", Json.bool(sayTestReply, "ok", false),
Json.str(sayTestReply, "error", "") + " 耗时 "
+ (System.currentTimeMillis() - t3) + "ms");
long t4 = System.currentTimeMillis();
Map<String, Object> featRunReply = session.call(
Json.obj("cmd", "feature_run", "name", "world_clock",
"params", Json.obj()), 25000);
check("feature_run 返回 ok", Json.bool(featRunReply, "ok", false),
Json.str(featRunReply, "error", "") + " 耗时 "
+ (System.currentTimeMillis() - t4) + "ms");
} catch (Exception e) {
check("功能页/语音页命令能调用", false, String.valueOf(e));
}
section("13. 屏幕镜像 / 截屏");
Map<String, Object> shot = session.call(Json.obj("cmd", "screenshot",
"maxWidth", 640, "quality", 70), 40000);
check("截屏返回成功", Json.bool(shot, "ok", false), brief(shot));
byte[] shotJpeg = B64.decode(Json.str(shot, "data", ""));
check("截屏是合法 JPEG", isJpeg(shotJpeg), shotJpeg.length + " 字节");
check("截屏尺寸有效",
Json.integer(shot, "w", 0) > 0 && Json.integer(shot, "h", 0) > 0,
Json.integer(shot, "w", 0) + "x" + Json.integer(shot, "h", 0));
check("截屏遵守 maxWidth", Json.integer(shot, "w", 9999) <= 640,
String.valueOf(Json.integer(shot, "w", 0)));
accumulator.resetFrames();
Map<String, Object> screenStart = session.call(Json.obj("cmd", "screen_start",
"fps", 4, "maxWidth", 320, "quality", 50), 25000);
check("开始屏幕镜像", Json.bool(screenStart, "ok", false), brief(screenStart));
long frameDeadline = System.currentTimeMillis() + 20000;
while (System.currentTimeMillis() < frameDeadline && accumulator.frames() < 3) {
Thread.sleep(150);
}
check("收到至少 3 帧镜像", accumulator.frames() >= 3,
"实际收到 " + accumulator.frames() + " 帧");
byte[] lastFrame = accumulator.lastFrame();
check("镜像帧是合法 JPEG", isJpeg(lastFrame),
lastFrame == null ? "null" : lastFrame.length + " 字节");
check("镜像帧遵守 maxWidth", accumulator.lastFrameWidth() <= 320,
String.valueOf(accumulator.lastFrameWidth()));
Map<String, Object> screenStop = session.call(Json.obj("cmd", "screen_stop"), 15000);
check("停止屏幕镜像", Json.bool(screenStop, "ok", false), brief(screenStop));
// ---------------- 14. 局域网自动发现 ----------------
section("14. 局域网自动发现(手表不用输 IP)");
// 定向广播地址计算 —— 这是真机上暴露出来的 bug:
// 原来写死猜 192.168.255.255,对 /24 根本不是本网段广播地址
check("定向广播 192.168.10.104/24 -> 192.168.10.255",
"192.168.10.255".equals(
com.lanremote.watch.DiscoveryCore.directedBroadcast("192.168.10.104", 24)),
String.valueOf(
com.lanremote.watch.DiscoveryCore.directedBroadcast("192.168.10.104", 24)));
check("定向广播 10.0.0.5/8 -> 10.255.255.255",
"10.255.255.255".equals(
com.lanremote.watch.DiscoveryCore.directedBroadcast("10.0.0.5", 8)),
String.valueOf(
com.lanremote.watch.DiscoveryCore.directedBroadcast("10.0.0.5", 8)));
check("定向广播 172.16.3.7/16 -> 172.16.255.255",
"172.16.255.255".equals(
com.lanremote.watch.DiscoveryCore.directedBroadcast("172.16.3.7", 16)),
String.valueOf(
com.lanremote.watch.DiscoveryCore.directedBroadcast("172.16.3.7", 16)));
check("定向广播 192.168.1.1/32 -> 自身",
"192.168.1.1".equals(
com.lanremote.watch.DiscoveryCore.directedBroadcast("192.168.1.1", 32)));
check("IP 与整数互转往返",
"192.168.10.104".equals(com.lanremote.watch.DiscoveryCore.longToIp(
com.lanremote.watch.DiscoveryCore.ipToLong("192.168.10.104"))));
// 子网扫描的地址列表(广播被拦时的兜底手段)
List<String> subnet = com.lanremote.watch.DiscoveryCore.subnetHosts(
"192.168.10.104", 24, 1024);
check("子网主机数正确 (/24 -> 254)", subnet.size() == 254, "得到 " + subnet.size());
if (subnet.size() == 254) {
check("子网首尾地址正确",
"192.168.10.1".equals(subnet.get(0))
&& "192.168.10.254".equals(subnet.get(253)),
subnet.get(0) + " .. " + subnet.get(253));
}
List<com.lanremote.watch.DiscoveryCore.Found> discovered =
com.lanremote.watch.DiscoveryCore.search(4000,
com.lanremote.watch.DiscoveryCore.directedBroadcast("127.0.0.1", 8));
check("广播搜索到被控端", !discovered.isEmpty(), "找到 " + discovered.size() + " 台");
com.lanremote.watch.DiscoveryCore.Found target = null;
for (com.lanremote.watch.DiscoveryCore.Found item : discovered) {
System.out.println(" 发现: " + item);
if (target == null) {
target = item;
}
}
// 优先挑端口匹配的那台(同一台电脑上可能还跑着别的实例)
for (com.lanremote.watch.DiscoveryCore.Found item : discovered) {
if (item.port == port) {
target = item;
break;
}
}
if (target != null) {
check("广播搜索结果里包含本次被控端(端口 " + port + ")",
target.port == port, "拿到的是端口 " + target.port);
check("发现的电脑名非空", target.name != null && target.name.length() > 0,
String.valueOf(target.name));
check("按发现结果确实能建立 TCP 连接",
com.lanremote.watch.DiscoveryCore.ping(target.host, target.port, 3000));
// 单播探测:诊断工具在"广播被拦"时用它判断到底是广播问题还是根本不通
com.lanremote.watch.DiscoveryCore.Found direct =
com.lanremote.watch.DiscoveryCore.probeHost("127.0.0.1", 3000);
check("单播探测指定 IP 有应答(诊断工具的兜底路径)",
direct != null,
direct == null ? "无应答" : "应答端口 " + direct.port);
}
// ---------------- 15. 断开处理 ----------------
section("14. 断开处理");
session.close();
Thread.sleep(500);
check("关闭后 isConnected 为 false", !session.isConnected());
System.out.println();
System.out.println("====================================================================");
System.out.println("通过 " + passed + " 项,失败 " + failed + " 项");
if (!failures.isEmpty()) {
System.out.println("失败项:");
for (String name : failures) {
System.out.println(" - " + name);
}
}
System.exit(failed == 0 ? 0 : 1);
}
private static Map<String, Object> waitForEvent(FileAccumulator acc, String eventName,
long timeoutMs) throws Exception {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
Map<String, Object> found = acc.pollEvent(eventName);
if (found != null) {
return found;
}
Thread.sleep(150);
}
return null;
}
/** 用 PowerShell 改本机剪贴板,模拟"用户在电脑上自己复制了东西"。 */
/** 剪贴板现在能不能用(没被别的程序占着)。 */
private static boolean clipboardUsable() {
try {
Process p = Runtime.getRuntime().exec(new String[]{
"powershell", "-NoProfile", "-Command",
"try { Set-Clipboard -Value ('probe-' + [guid]::NewGuid()); "
+ "'ok' } catch { 'locked' }"});
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(p.getInputStream(), "UTF-8"));
String line;
while ((line = reader.readLine()) != null) {
if (line.trim().equalsIgnoreCase("ok")) {
return true;
}
if (line.trim().equalsIgnoreCase("locked")) {
return false;
}
}
p.waitFor();
return false;
} catch (Exception e) {
return false;
}
}
private static void triggerLocalClipboard() {
try {
Runtime.getRuntime().exec(new String[]{
"powershell", "-NoProfile", "-Command",
"Set-Clipboard -Value 'java-local-clip-" + System.nanoTime() + "'"});
} catch (Exception ignored) {
// 环境不支持时忽略,靠轮询依然可能收到事件
}
}
/** 生成一个真实可播放的 WAV(16bit 单声道正弦波)。 */
private static byte[] makeWav(int seconds, int sampleRate) {
int samples = sampleRate * seconds;
int dataSize = samples * 2;
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
try {
out.write("RIFF".getBytes("US-ASCII"));
out.write(intLE(36 + dataSize));
out.write("WAVE".getBytes("US-ASCII"));
out.write("fmt ".getBytes("US-ASCII"));
out.write(intLE(16));
out.write(shortLE((short) 1)); // PCM
out.write(shortLE((short) 1)); // 单声道
out.write(intLE(sampleRate));
out.write(intLE(sampleRate * 2)); // 字节率
out.write(shortLE((short) 2)); // block align
out.write(shortLE((short) 16)); // 位深
out.write("data".getBytes("US-ASCII"));
out.write(intLE(dataSize));
for (int i = 0; i < samples; i++) {
double wave = Math.sin(2 * Math.PI * 440 * i / sampleRate);
out.write(shortLE((short) (3000 * wave)));
}
} catch (Exception ignored) {
// 内存写入不会失败
}
return out.toByteArray();
}
private static boolean isJpeg(byte[] data) {
return data != null && data.length > 4
&& (data[0] & 0xFF) == 0xFF && (data[1] & 0xFF) == 0xD8
&& (data[data.length - 2] & 0xFF) == 0xFF
&& (data[data.length - 1] & 0xFF) == 0xD9;
}
/** 把回包压成一行,便于失败时看原因。 */
private static String brief(Map<String, Object> message) {
if (message == null) {
return "null";
}
String text = String.valueOf(message);
if (text.length() > 160) {
// 截屏回包里有大段 base64,打印时裁掉
text = text.replaceAll("\"data\":\"[^\"]{40,}\"", "\"data\":\"<省略>\"");
}
return text.length() > 200 ? text.substring(0, 200) + "…" : text;
}
private static String hex(byte[] data, int count) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < Math.min(count, data.length); i++) {
sb.append(String.format("%02x ", data[i] & 0xFF));
}
return sb.toString().trim();
}
private static byte[] slice(byte[] data, int from, int count) {
int to = Math.min(data.length, from + count);
return Arrays.copyOfRange(data, from, Math.max(from, to));
}
/** 计算 SHA-256 前 16 位十六进制,用于三方比对。 */
private static String sha256(byte[] data) { try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(data);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 8; i++) {
sb.append(String.format("%02x", hash[i]));
}
return sb.toString();
} catch (Exception e) {
return "n/a";
}
}
private static byte[] intLE(int value) { return new byte[]{
(byte) (value & 0xFF), (byte) ((value >> 8) & 0xFF),
(byte) ((value >> 16) & 0xFF), (byte) ((value >> 24) & 0xFF)};
}
private static byte[] shortLE(short value) {
return new byte[]{(byte) (value & 0xFF), (byte) ((value >> 8) & 0xFF)};
}
/** 一张真实的 64x48 PNG。 */
private static final String TEST_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAkUlEQVR4nO3ZsQ3CUBAE0fGIOqAM"
+ "WoMKaM2U8TuhApwgYc+XX3rJSbvR3XJ9rJRJnMRJnMRJnMRJ3OXbYLzuHMzt+Z4wAZm1QtvB/dPY"
+ "LHM+AYmTOImTOImTOImTOImTOImTOImTOImTOImTOImTOImTOImTOJn+tDiOd6aeKgGJW85H984k"
+ "TuIkTuLce4FffQD4RwrAdUul6AAAAABJRU5ErkJggg==";
/** 收集 file 事件分块、屏幕镜像帧,同时缓存其他事件。 */
static class FileAccumulator implements RemoteClient.Listener {
private final java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
private final java.util.concurrent.ConcurrentLinkedQueue<Map<String, Object>> events =
new java.util.concurrent.ConcurrentLinkedQueue<Map<String, Object>>();
private volatile boolean complete = false;
private volatile int frameCount = 0;
private volatile byte[] lastFrame = null;
private volatile int lastFrameWidth = 0;
private volatile int lastFrameHeight = 0;
void reset() {
synchronized (buffer) {
buffer.reset();
complete = false;
}
}
void resetFrames() {
frameCount = 0;
lastFrame = null;
lastFrameWidth = 0;
lastFrameHeight = 0;
}
int frames() {
return frameCount;
}
byte[] lastFrame() {
return lastFrame;
}
int lastFrameWidth() {
return lastFrameWidth;
}
int lastFrameHeight() {
return lastFrameHeight;
}
int size() {
synchronized (buffer) {
return buffer.size();
}
}
boolean ready() {
return complete;
}
byte[] data() {
synchronized (buffer) {
return buffer.toByteArray();
}
}
Map<String, Object> pollEvent(String name) {
for (Map<String, Object> event : events) {
if (name.equals(Json.str(event, "event", ""))) {
events.remove(event);
return event;
}
}
return null;
}
@Override
public void onEvent(String event, Map<String, Object> message) {
if ("frame".equals(event)) {
byte[] jpeg = B64.decode(Json.str(message, "data", ""));
frameCount++;
lastFrame = jpeg;
lastFrameWidth = Json.integer(message, "w", 0);
lastFrameHeight = Json.integer(message, "h", 0);
return;
}
if ("file".equals(event)) {
byte[] chunk = B64.decode(Json.str(message, "data", ""));
synchronized (buffer) {
buffer.write(chunk, 0, chunk.length);
if (Json.bool(message, "last", false)) {
complete = true;
}
}
} else {
events.add(message);
}
}
@Override
public void onDisconnected(String reason) {
System.out.println(" (连接断开: " + reason + ")");
}
}
static class SilentListener implements RemoteClient.Listener {
@Override
public void onEvent(String event, Map<String, Object> message) {
}
@Override
public void onDisconnected(String reason) {
}
}
}