package com.lanremote.watch; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketTimeoutException; import java.nio.charset.Charset; import java.util.Arrays; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; /** * 被控端协议客户端(纯 Java,无 Android 依赖)。 * * 之所以保持纯 Java:可以把它编译到桌面 JVM,直接连真实的 Python 被控端做联调, * 而不是只靠"看起来应该没问题"。 */ public class RemoteClient { public static final Charset UTF8 = Charset.forName("UTF-8"); public static final int CHUNK_SIZE = 256 * 1024; public static final int DEFAULT_PORT = 8765; /** 事件与断开回调。注意:回调发生在读取线程,UI 层需自行切主线程。 */ public interface Listener { void onEvent(String event, Map message); void onDisconnected(String reason); } /** * 传输层。局域网直连时是 SocketTransport,跨网络时是 RelayTransport。 * 抽出来之后协议逻辑完全不用管数据是走 TCP 还是走中转的 HTTPS。 */ private Transport transport; private BufferedReader reader; private OutputStream writer; private Thread readerThread; /** 单行上限,和服务端的 MAX_LINE_BYTES 对齐 */ private static final int MAX_LINE_CHARS = 8 * 1024 * 1024; private volatile boolean running; private final BlockingQueue> replies = new LinkedBlockingQueue>(); private final Object sendLock = new Object(); private Listener listener; private String serverName = ""; private String deviceId = ""; private String deviceName = "手表"; // ------------------------------------------------------------------ public boolean isConnected() { return running && transport != null && !transport.isClosed(); } /** 当前传输方式的描述,界面上用来显示"直连"还是"经中转"。 */ public String transportName() { if (transport == null) { return ""; } return transport instanceof RelayTransport ? "中转" : "直连"; } public RelayTransport relayTransport() { return transport instanceof RelayTransport ? (RelayTransport) transport : null; } public String getServerName() { return serverName; } public void setDevice(String deviceId, String deviceName) { this.deviceId = deviceId == null ? "" : deviceId; this.deviceName = deviceName == null ? "手表" : deviceName; } // ------------------------------------------------------------------ // 连接与握手 // ------------------------------------------------------------------ /** * 连接并完成握手。 * * @param token 为空则用配对码配对;非空则直接令牌登录(下次打开免配对码) * @return 服务端握手回包,含 ok / token / serverName */ public Map connect(String host, int port, String code, String token, Listener listener, int timeoutMs) throws IOException { close(); this.listener = listener; this.running = true; this.replies.clear(); Socket sock = new Socket(); sock.connect(new InetSocketAddress(host, port), timeoutMs); sock.setSoTimeout(0); sock.setTcpNoDelay(true); return startTransport(new Transport.SocketTransport(sock), code, token, listener, timeoutMs); } /** * 经中转服务器连接(跨网络)。 * * 手表没法直连电脑(两边都在 NAT 后面),所以两边都主动连出去, * 由中转按配对码把两条连接对上。中转只转发字节、不认识协议内容, * 所以电脑那边的配对和令牌校验照样生效。 */ /** 上次经中转接入时,被控端报的局域网 IP 和端口。 */ private java.util.List relayLanIps = new java.util.ArrayList(); private int relayLanPort; public java.util.List relayLanIps() { return relayLanIps; } public int relayLanPort() { return relayLanPort; } public Map connectViaRelay(String baseUrl, String code, String token, Listener listener, int timeoutMs) throws IOException { close(); relayLanIps = new java.util.ArrayList(); relayLanPort = 0; RelayTransport relay = new RelayTransport(baseUrl, code, deviceName); if (!relay.attach()) { String reason = relay.getLastError(); if (reason == null || reason.isEmpty()) { reason = "连不上中转服务器"; } relay.close(); // 和局域网那条路保持一致:握手被拒要回 ok=false,不要抛异常。 // 抛异常的话界面只能显示"连不上",分不清是配对码错了、 // 设备不在线、还是中转服务器连不上 —— 这三者的处理方式完全不同。 this.serverName = ""; return Json.obj("ok", false, "error", reason); } Map reply = startTransport(relay, code, token, listener, timeoutMs); if (Json.bool(reply, "ok", false)) { // 留给界面判断:要不要切成局域网直连 relayLanIps = relay.getLanIps(); relayLanPort = relay.getLanPort(); } return reply; } /** 握手 + 起读取线程。局域网和中转共用这一段。 */ private Map startTransport(Transport channel, String code, String token, Listener listener, int timeoutMs) throws IOException { this.listener = listener; this.running = true; this.replies.clear(); this.transport = channel; this.reader = new BufferedReader( new InputStreamReader(channel.input(), UTF8), 8192); this.writer = channel.output(); Thread thread = new Thread(new Runnable() { @Override public void run() { readLoop(); } }, "lanremote-reader"); thread.setDaemon(true); thread.start(); this.readerThread = thread; Map handshake; if (token != null && token.length() > 0) { handshake = Json.obj("cmd", "auth", "deviceId", deviceId, "deviceName", deviceName, "token", token); } else { handshake = Json.obj("cmd", "pair", "deviceId", deviceId, "deviceName", deviceName, "code", code == null ? "" : code.trim()); } Map reply; try { reply = call(handshake, timeoutMs); } catch (IOException failed) { // 握手失败必须关掉,否则 socket 和读取线程泄漏, // 而且 isConnected() 会一直是 true,界面以为连上了。 close(); throw failed; } if (Json.bool(reply, "ok", false)) { this.serverName = Json.str(reply, "serverName", ""); } else { close(); } return reply; } public void close() { running = false; try { if (transport != null) { transport.close(); } } catch (Exception ignored) { // 关闭时的异常无需处理 } transport = null; reader = null; writer = null; replies.clear(); } // ------------------------------------------------------------------ // 收发 // ------------------------------------------------------------------ private void readLoop() { try { // close() 会把 reader 置 null,而 running 判断和 readLine 之间有竞态。 // 先取到局部变量并判空,避免 NPE —— 读取线程里的未捕获 NPE 会杀掉整个进程。 BufferedReader localReader = this.reader; if (localReader == null) { return; } String line; while (running && (line = localReader.readLine()) != null) { if (line.length() > MAX_LINE_CHARS) { continue; // 对端发超长单行,直接丢弃防止占满内存 } if (line.trim().isEmpty()) { continue; } Map message; try { message = Json.parseObject(line); } catch (Exception malformed) { continue; // 单条报文损坏不影响整条连接 } if (message.containsKey("event")) { if (listener != null) { listener.onEvent(String.valueOf(message.get("event")), message); } } else { replies.offer(message); } } } catch (IOException ignored) { // 连接被关闭或网络中断,走下面的统一处理 } finally { boolean notify = running; running = false; if (notify && listener != null) { listener.onDisconnected("连接已断开"); } } } private void send(Map message) throws IOException { OutputStream out = writer; if (out == null) { throw new IOException("尚未连接"); } byte[] payload = (Json.write(message) + "\n").getBytes(UTF8); synchronized (sendLock) { out.write(payload); out.flush(); } } /** 发一条命令并等待应答。 */ public Map call(Map message, long timeoutMs) throws IOException { if (!isConnected()) { throw new IOException("尚未连接"); } send(message); return awaitReply(timeoutMs); } private Map awaitReply(long timeoutMs) throws IOException { try { Map reply = replies.poll(timeoutMs, TimeUnit.MILLISECONDS); if (reply == null) { throw new SocketTimeoutException("等待服务端响应超时"); } return reply; } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException("等待响应被中断"); } } public Map sendString(String cmd, String key, String value, long timeoutMs) throws IOException { Map message = Json.obj("cmd", cmd); message.put(key, value); return call(message, timeoutMs); } // ------------------------------------------------------------------ // 分块传输(图片 / 音频 / 文件) // ------------------------------------------------------------------ public Map sendChunks(String cmd, String name, byte[] blob, long timeoutMs) throws IOException { int total = blob.length == 0 ? 1 : (blob.length + CHUNK_SIZE - 1) / CHUNK_SIZE; for (int index = 0; index < total; index++) { int from = index * CHUNK_SIZE; int to = Math.min(blob.length, from + CHUNK_SIZE); byte[] piece = blob.length == 0 ? new byte[0] : Arrays.copyOfRange(blob, from, to); Map message = Json.obj( "cmd", cmd, "name", name, "seq", index, "last", index == total - 1, "data", B64.encode(piece)); send(message); } return awaitReply(timeoutMs); } // ------------------------------------------------------------------ // 便捷封装 // ------------------------------------------------------------------ public boolean ping(long timeoutMs) { try { return Json.bool(call(Json.obj("cmd", "ping"), timeoutMs), "pong", false); } catch (IOException e) { return false; } } }