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();
}
}
}