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 failures = new ArrayList(); 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 sample = Json.obj("a", 1, "b", "中文\"引号\\反斜杠", "c", true); String encoded = Json.write(sample); Map 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 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 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 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 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 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 info = session.call(Json.obj("cmd", "info"), 8000); Map 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 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 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 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 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 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 listing = session.call(Json.obj("cmd", "file_list"), 8000); boolean found = false; for (Object item : Json.list(listing, "files")) { Map 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 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 clipEvent = waitForEvent(accumulator, "clip", 20000); check("收到电脑本地剪贴板变化推送", clipEvent != null, clipEvent == null ? "超时未收到" : "ok"); } // ---------------- 13. 屏幕镜像 / 截屏 ---------------- // ---------------- 12. 功能页 / 语音页 ---------------- section("12. 功能页 / 语音页(用户报连不上的那几个)"); try { long t0 = System.currentTimeMillis(); Map featListReply = session.call(Json.obj("cmd", "feature_list"), 25000); long cost = System.currentTimeMillis() - t0; java.util.List 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 first = (Map) 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 voiceListReply = session.call(Json.obj("cmd", "voice_list"), 25000); java.util.List 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 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 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 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 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 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 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 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 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 waitForEvent(FileAccumulator acc, String eventName, long timeoutMs) throws Exception { long deadline = System.currentTimeMillis() + timeoutMs; while (System.currentTimeMillis() < deadline) { Map 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 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> events = new java.util.concurrent.ConcurrentLinkedQueue>(); 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 pollEvent(String name) { for (Map event : events) { if (name.equals(Json.str(event, "event", ""))) { events.remove(event); return event; } } return null; } @Override public void onEvent(String event, Map 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 message) { } @Override public void onDisconnected(String reason) { } } }