package com.lanremote.watch; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; /** * 走中转服务器的传输层(跨网络用)。 * * 原理: * 手表没法直连电脑(两边都在 NAT 后面),所以两边都主动连出去: * * 手表 --HTTPS--> 中转服务器 <--HTTPS-- 电脑 * * 中转只按配对码把两条连接对上,中间传的是**原始字节**,它不认识内容。 * 所以电脑那边的配对、令牌校验、权限判断全部继续生效 —— * 中转被攻破也进不了用户的电脑。 * * 为什么用长轮询而不是 WebSocket: * 长轮询在任何代理 / 4G 网络下都最稳,而且 Android 自带 * HttpURLConnection 就能做,不需要引任何第三方库 * (这个项目手表端是**零第三方依赖**的纯 Java)。 * * 数据流: * 上行(手表->电脑):write() 攒进缓冲区 -> 发送线程 POST /v1/client/send * 下行(电脑->手表):轮询线程 GET 长轮询 -> 塞进 inbox -> read() 取走 */ public class RelayTransport implements Transport { /** 单次发送攒够这么多就立刻发,别等定时器 */ private static final int FLUSH_BYTES = 32 * 1024; /** 攒不住了也要发,别让命令卡住 */ private static final int FLUSH_MILLIS = 40; /** * 长轮询:中转那边挂 60 秒,客户端超时给宽一点。 * *

必须显著大于服务端的 hold 时间,否则会在服务端正常返回 * 之前自己先超时 —— 那就变成"每次轮询都失败"了。 * *

180 秒 = 60(hold) + 120(网络余量)。 * 余量一直在加大,因为实测的长尾一直在刷新: * 90 秒时实测到 93589ms,120 秒时实测到 126604ms * (30 次里 1 次,其余都是 61~62 秒)。 * 4G 丢包触发的重传退避能拖出一分钟以上的停顿,这是链路特性。 * 放宽超时只是"别把慢但成功的请求冤枉掉",不增加请求数。 */ private static final int POLL_TIMEOUT_MS = 180000; /** 发送/接收的单次请求超时 */ private static final int SEND_TIMEOUT_MS = 30000; /** 网络出错后的退避 */ private static final int RETRY_MIN_MS = 1500; private static final int RETRY_MAX_MS = 15000; /** * 连续失败几次才判定"离线"。 * *

单次失败就报离线太敏感:4G 链路上随手就有 2~4% 的偶发失败, * 而链路通常一两秒就自己恢复,界面却已经闪了一下"已断开"。 * 改成连续 3 次(至少 4.5 秒都不通)才判离线 —— * 真的断了仍然会如实显示。 */ private static final int OFFLINE_AFTER = 3; /** 单次读到的字节上限 */ private static final int MAX_BODY = 8 * 1024 * 1024; private final String baseUrl; private final String pairingCode; private final String clientName; private volatile String session = ""; private volatile String deviceName = ""; private volatile boolean closed; /** 是否被另一台手表顶替(这种情况下不该重连) */ private volatile boolean replaced; private volatile String lastError = ""; private volatile boolean online; /** 收到的字节,按块排队,read() 按需取 */ private final LinkedBlockingQueue inbox = new LinkedBlockingQueue(); private byte[] current; private int currentOffset; /** 待发送的字节 */ private final ByteArrayOutputStream pending = new ByteArrayOutputStream(); private final Object pendingLock = new Object(); private Thread pollThread; /** 连续失败次数(成功一次就清零)。 */ private volatile int failStreak; private Thread sendThread; private final Object closingLock = new Object(); public RelayTransport(String baseUrl, String pairingCode, String clientName) { String url = baseUrl == null ? "" : baseUrl.trim(); while (url.endsWith("/")) { url = url.substring(0, url.length() - 1); } if (!url.startsWith("http://") && !url.startsWith("https://")) { url = "https://" + url; } this.baseUrl = url; this.pairingCode = pairingCode == null ? "" : pairingCode.trim(); this.clientName = clientName == null || clientName.isEmpty() ? "手表" : clientName; } public String getDeviceName() { return deviceName; } public String getLastError() { return lastError; } public boolean isOnline() { return online && !closed; } /** 是不是被另一台手表接管了。界面据此提示"已被接管"而不是"连接失败"。 */ public boolean isReplaced() { return replaced; } public String getBaseUrl() { return baseUrl; } // ------------------------------------------------------------------ // Transport // ------------------------------------------------------------------ @Override public InputStream input() { return new InputStream() { @Override public int read() throws IOException { byte[] one = new byte[1]; int got = read(one, 0, 1); return got <= 0 ? -1 : (one[0] & 0xFF); } @Override public int read(byte[] buffer, int offset, int length) throws IOException { if (length <= 0) { return 0; } while (current == null || currentOffset >= current.length) { if (closed) { return -1; } try { current = inbox.poll(500, TimeUnit.MILLISECONDS); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return -1; } currentOffset = 0; } int available = current.length - currentOffset; int take = Math.min(available, length); System.arraycopy(current, currentOffset, buffer, offset, take); currentOffset += take; if (currentOffset >= current.length) { current = null; currentOffset = 0; } return take; } @Override public int available() { int total = 0; if (current != null) { total += current.length - currentOffset; } for (byte[] piece : inbox) { total += piece.length; } return total; } }; } @Override public OutputStream output() { return new OutputStream() { @Override public void write(int value) { write(new byte[]{(byte) value}, 0, 1); } @Override public void write(byte[] buffer, int offset, int length) { if (length <= 0) { return; } synchronized (pendingLock) { pending.write(buffer, offset, length); if (pending.size() >= FLUSH_BYTES) { pendingLock.notifyAll(); } } } }; } @Override public boolean isClosed() { return closed; } @Override public void close() { synchronized (closingLock) { if (closed) { return; } closed = true; } // 尽量把手里的东西发出去,再把会话摘掉 flushNow(); if (!session.isEmpty()) { try { request("/v1/client/detach", new byte[0], true, 8000); } catch (IOException ignored) { // 断网时摘不掉也没关系,中转会自己清 } } if (pollThread != null) { pollThread.interrupt(); } if (sendThread != null) { sendThread.interrupt(); } synchronized (pendingLock) { pendingLock.notifyAll(); } } @Override public String describe() { return "中转服务器"; } public String getSession() { return session; } // ------------------------------------------------------------------ // 连接 // ------------------------------------------------------------------ /** 接入中转。成功返回 true。 */ /** 被控端上报的局域网 IP(可能多个网卡)。判断同网段用。 */ private java.util.List lanIps = new java.util.ArrayList(); /** 被控端监听端口。0 表示它没报(那就没法直连)。 */ private int lanPort; public java.util.List getLanIps() { return lanIps; } public int getLanPort() { return lanPort; } public boolean attach() { try { String body = "{\"code\":\"" + Json.escape(pairingCode) + "\",\"name\":\"" + Json.escape(clientName) + "\"}"; HttpResult result = request("/v1/client/attach", body.getBytes(RemoteClient.UTF8), false, 30000); if (result.code != 200) { lastError = parseError(result.body, "接入被拒(" + result.code + ")"); return false; } java.util.Map reply = Json.parseObject(new String(result.body, RemoteClient.UTF8)); if (!Json.bool(reply, "ok", false)) { lastError = Json.str(reply, "error", "接入被拒"); return false; } session = Json.str(reply, "session", ""); deviceName = Json.str(reply, "deviceName", ""); // 中转顺便告诉我们「这台电脑在哪个局域网、开在哪个端口」。 // 手表拿它判断要不要切成局域网直连。 lanIps = Json.stringList(reply, "lanIps"); lanPort = Json.integer(reply, "port", 0); if (session.isEmpty()) { lastError = "中转没给会话令牌"; return false; } online = true; lastError = ""; } catch (Exception exc) { lastError = "连不上中转:" + exc.getClass().getSimpleName() + (exc.getMessage() == null ? "" : " " + exc.getMessage()); return false; } pollThread = new Thread(new Runnable() { @Override public void run() { pollLoop(); } }, "relay-poll"); pollThread.setDaemon(true); pollThread.start(); sendThread = new Thread(new Runnable() { @Override public void run() { sendLoop(); } }, "relay-send"); sendThread.setDaemon(true); sendThread.start(); return true; } // ------------------------------------------------------------------ // 轮询:电脑 -> 手表 // ------------------------------------------------------------------ private void pollLoop() { int backoff = RETRY_MIN_MS; while (!closed) { try { HttpResult result = request("/v1/client/poll", new byte[0], true, POLL_TIMEOUT_MS); if (result.code == 409) { // 被另一台手表接管了。这里**必须断开**,不能重连 —— // 否则两台手表会互相把对方顶掉,永远握不上手。 online = false; lastError = "已被另一台手表接管"; replaced = true; closed = true; return; } if (result.code == 401 || result.code == 403) { // 会话过期(中转重启等)—— 这种可以自动重连 online = false; lastError = "会话已失效,正在重新接入"; if (!reattach()) { sleep(backoff); backoff = Math.min(RETRY_MAX_MS, backoff * 2); continue; } backoff = RETRY_MIN_MS; continue; } if (result.code != 200) { // 偶发的 502/504 不算断线,连续几次才算 failStreak++; if (failStreak >= OFFLINE_AFTER) { online = false; } lastError = "轮询返回 " + result.code; sleep(backoff); backoff = Math.min(RETRY_MAX_MS, backoff * 2); continue; } online = true; failStreak = 0; lastError = ""; backoff = RETRY_MIN_MS; if (result.body.length > 0) { inbox.offer(result.body); } } catch (Exception exc) { if (closed) { return; } // 连接超时/重置很常见(隧道在重连),连续几次才算真断 failStreak++; if (failStreak >= OFFLINE_AFTER) { online = false; } lastError = "轮询出错:" + exc.getClass().getSimpleName(); sleep(backoff); backoff = Math.min(RETRY_MAX_MS, backoff * 2); } } } private boolean reattach() { try { String body = "{\"code\":\"" + Json.escape(pairingCode) + "\",\"name\":\"" + Json.escape(clientName) + "\"}"; HttpResult result = request("/v1/client/attach", body.getBytes(RemoteClient.UTF8), false, 30000); java.util.Map reply = Json.parseObject(new String(result.body, RemoteClient.UTF8)); String fresh = Json.str(reply, "session", ""); if (Json.bool(reply, "ok", false) && !fresh.isEmpty()) { session = fresh; deviceName = Json.str(reply, "deviceName", deviceName); return true; } lastError = Json.str(reply, "error", "重新接入失败"); return false; } catch (Exception exc) { lastError = "重新接入出错:" + exc.getClass().getSimpleName(); return false; } } // ------------------------------------------------------------------ // 发送:手表 -> 电脑 // ------------------------------------------------------------------ private void sendLoop() { while (!closed) { byte[] chunk = takePending(); if (chunk == null) { continue; } try { HttpResult result = request("/v1/client/send", chunk, true, SEND_TIMEOUT_MS); if (result.code != 200) { lastError = "发送返回 " + result.code; } } catch (Exception exc) { if (closed) { return; } lastError = "发送出错:" + exc.getClass().getSimpleName(); sleep(1500); } } } private byte[] takePending() { synchronized (pendingLock) { if (pending.size() == 0) { try { pendingLock.wait(FLUSH_MILLIS); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return null; } if (pending.size() == 0) { return null; } } byte[] chunk = pending.toByteArray(); pending.reset(); return chunk; } } private void flushNow() { byte[] chunk; synchronized (pendingLock) { if (pending.size() == 0) { return; } chunk = pending.toByteArray(); pending.reset(); } try { request("/v1/client/send", chunk, true, SEND_TIMEOUT_MS); } catch (Exception ignored) { // 关闭时发不出去就算了 } } // ------------------------------------------------------------------ // HTTP // ------------------------------------------------------------------ private static final class HttpResult { final int code; final byte[] body; HttpResult(int code, byte[] body) { this.code = code; this.body = body; } } private HttpResult request(String path, byte[] body, boolean withSession, int timeoutMs) throws IOException { // 走 TlsCompat:老手表缺根证书时用内置的那两个补上。 // 不这么改会报 SSLHandshakeException: Chain validation failed // (廉价手表的系统根证书库常缺 GTS Root R4)。 HttpURLConnection connection = TlsCompat.open(baseUrl + path); try { connection.setRequestMethod("POST"); connection.setDoOutput(body.length > 0); connection.setConnectTimeout(Math.min(15000, timeoutMs)); connection.setReadTimeout(timeoutMs); connection.setUseCaches(false); connection.setRequestProperty("Content-Type", "application/octet-stream"); connection.setRequestProperty("User-Agent", "LanRemote-Watch/1.5"); if (withSession) { connection.setRequestProperty("X-Session", session); } if (body.length > 0) { connection.setFixedLengthStreamingMode(body.length); OutputStream out = connection.getOutputStream(); out.write(body); out.flush(); out.close(); } int code = connection.getResponseCode(); InputStream stream = code >= 400 ? connection.getErrorStream() : connection.getInputStream(); byte[] payload = stream == null ? new byte[0] : readAll(stream); return new HttpResult(code, payload); } finally { connection.disconnect(); } } private static byte[] readAll(InputStream stream) throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] piece = new byte[16384]; int total = 0; int got; while ((got = stream.read(piece)) > 0) { total += got; if (total > MAX_BODY) { break; } buffer.write(piece, 0, got); } return buffer.toByteArray(); } private static String parseError(byte[] body, String fallback) { try { java.util.Map reply = Json.parseObject(new String(body, RemoteClient.UTF8)); String error = Json.str(reply, "error", ""); return error.isEmpty() ? fallback : error; } catch (Exception ignored) { return fallback; } } private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } } }