Files
ykxmz/_tests/java/JavaClientTest.java
T
xmz7891 b62ac865ea ykxmz 1.5.0:修复手表端 TLS 证书校验 + MTU 修复网络稳定性
被控端
- 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%)
- 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败)
- 每分钟回头找局域网,自动切回快 400 倍的路径
- 画面心跳帧:静止时省流量,客户端仍知道流是活的
- 锁屏时给出可操作的提示

手表端
- 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException
- 连接错误翻译成人话(证书/时间/解析/超时分别提示)
- 经中转连上后用服务器报的地址刷新已配对记录
- 新增局域网连接页面、服务器延迟检测

服务器端
- 支持 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) {
}
}
}