Files
ykxmz/android/src/com/lanremote/watch/RemoteClient.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

343 lines
12 KiB
Java

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<String, Object> 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<Map<String, Object>> replies =
new LinkedBlockingQueue<Map<String, Object>>();
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<String, Object> 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<String> relayLanIps = new java.util.ArrayList<String>();
private int relayLanPort;
public java.util.List<String> relayLanIps() {
return relayLanIps;
}
public int relayLanPort() {
return relayLanPort;
}
public Map<String, Object> connectViaRelay(String baseUrl, String code,
String token, Listener listener,
int timeoutMs) throws IOException {
close();
relayLanIps = new java.util.ArrayList<String>();
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<String, Object> reply =
startTransport(relay, code, token, listener, timeoutMs);
if (Json.bool(reply, "ok", false)) {
// 留给界面判断:要不要切成局域网直连
relayLanIps = relay.getLanIps();
relayLanPort = relay.getLanPort();
}
return reply;
}
/** 握手 + 起读取线程。局域网和中转共用这一段。 */
private Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> call(Map<String, Object> message, long timeoutMs)
throws IOException {
if (!isConnected()) {
throw new IOException("尚未连接");
}
send(message);
return awaitReply(timeoutMs);
}
private Map<String, Object> awaitReply(long timeoutMs) throws IOException {
try {
Map<String, Object> 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<String, Object> sendString(String cmd, String key, String value, long timeoutMs)
throws IOException {
Map<String, Object> message = Json.obj("cmd", cmd);
message.put(key, value);
return call(message, timeoutMs);
}
// ------------------------------------------------------------------
// 分块传输(图片 / 音频 / 文件)
// ------------------------------------------------------------------
public Map<String, Object> 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<String, Object> 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;
}
}
}