mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 13:40:45 +00:00
修「经中转几次后永久哑掉」——「按一下没反应」的真因
== 现象 ==
用户报:
* 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
* 走中转「已配对」,但点按钮没反应
局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。
== 证据链(插桩复现出来的)==
被控端日志里直接抛了未捕获异常,线程当场死:
Exception in thread client-relay:
server.py:407 session.send(reply)
relay.py:166 self._link.send_to_client(data)
relay.py:357 self._post("/v1/agent/send", ...)
relay.py:766 raise RelayError(f"请求失败: {last_error}")
lanremote.relay.RelayError: 请求失败: Idle
之后的状态:
* client-relay 线程没了 —— 电脑再也不往手表回任何东西
* relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
* **手表看到的就是「连上了,但按一下毫无反应」**
写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。
== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==
_post() 里锁只包住「取连接」那一段:
with self._http_lock:
connection = self._http # 锁到这里就放了
try:
connection.request("POST", ...) # 锁外发请求
于是两个线程会同时用同一条 connection:
* relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
* client-relay 线程同时 POST /v1/agent/send
HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。
_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。
修法:
* **发命令用独立连接**(_post 加 dedicated 参数)。
长轮询那条常年被占着,发命令不该去挤它,各走各的。
* 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
不再只锁取连接那一下。
* 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
* _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
(否则那条连接会被永久标记为忙)。
== 根因 2:回包失败会打死整个会话线程 ==
server.py 的 _client_loop 里:
if reply is not None:
session.send(reply) # <- 不在任何 try 里
...
except OSError: # <- 外层只捕获 OSError
pass
而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。
修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。
== 修复效果 ==
修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常
_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**
这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。
== 同批修掉验证阶段找到的其它问题 ==
1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
register() 用 `device_id in self.devices` 判断老设备,但超时清理
把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
第二天开机就会被自己的开关挡住,而且**重启也救不回来**
(清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
管理员在页面上还看不到这台设备,不知道该放开谁。
修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
register 改查这份历史;清理后补 _save();delete_device 才真正忘记。
2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
`\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
元凶是我加改码功能那次提交。改成 `\\n`。
3. **--relay / --relay-status 崩溃**(严重)
RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
1 required positional argument」(用户以为地址填错了),
一处直接报栈。两处都补上 url。
4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
字母数字 + - _ . :)
5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
返回值反映真实运行状态;不再用「对象建过没有」判断)
6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节
== 局域网全面优化(task-2,另一个智能体完成)==
4 个真问题:
* **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
被判成不同网络,明明能直连却去走中转还费流量。
改成按网卡真实掩码比较。
* **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
* **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
* **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
新增定期回切(先便宜地 TCP ping,通了才动真格,
不破坏正在工作的中转连接)。
实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
现已修复。)
顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。
== 服务端一键部署脚本(task-1,另一个智能体完成)==
relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
* 自动检查并安装 cloudflared
* 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
* **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
不对外开放,绝不能把私人域名烧成默认值)
* 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动
顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。
== 默认中转地址改成空白 ==
用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。
== 文档同步(task-5,另一个智能体完成)==
README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
* 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
* 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
* 删掉「地址已经内置好了」(现在是错的)
* AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
* RELAY_README 原来写「两条隧道同时跑会互相争抢」,
和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
This commit is contained in:
@@ -1,168 +1,221 @@
|
||||
package com.lanremote.watch;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.WifiManager;
|
||||
|
||||
import java.net.Inet4Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InterfaceAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 安卓侧的自动发现包装。
|
||||
*
|
||||
* 真正干活的是 {@link DiscoveryCore}(纯 Java,可桌面联调)。这里多做两件事:
|
||||
* 1. 抓 WifiManager.MulticastLock(部分手表息屏/省电时会过滤广播)
|
||||
* 2. 算出**本网段的定向广播地址**,这是广播探测能不能收到回复的关键
|
||||
*/
|
||||
public final class Discovery {
|
||||
|
||||
public static final int PORT = DiscoveryCore.PORT;
|
||||
|
||||
private Discovery() {
|
||||
}
|
||||
|
||||
/** 本机在某张网卡上的 IPv4 与前缀长度。 */
|
||||
public static class LocalNet {
|
||||
public final String ip;
|
||||
public final int prefix;
|
||||
public final String iface;
|
||||
|
||||
LocalNet(String ip, int prefix, String iface) {
|
||||
this.ip = ip;
|
||||
this.prefix = prefix;
|
||||
this.iface = iface;
|
||||
}
|
||||
|
||||
public String broadcast() {
|
||||
return DiscoveryCore.directedBroadcast(ip, prefix);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return iface + " " + ip + "/" + prefix;
|
||||
}
|
||||
}
|
||||
|
||||
/** 取当前可用的局域网 IPv4(优先 wlan*)。 */
|
||||
public static LocalNet localNet() {
|
||||
try {
|
||||
LocalNet fallback = null;
|
||||
for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
|
||||
if (!ni.isUp() || ni.isLoopback()) {
|
||||
continue;
|
||||
}
|
||||
String name = ni.getName() == null ? "" : ni.getName();
|
||||
if (name.startsWith("p2p") || name.startsWith("dummy")) {
|
||||
continue; // WiFi Direct / 占位网卡
|
||||
}
|
||||
for (InterfaceAddress ia : ni.getInterfaceAddresses()) {
|
||||
InetAddress addr = ia.getAddress();
|
||||
if (!(addr instanceof Inet4Address) || addr.isLoopbackAddress()) {
|
||||
continue;
|
||||
}
|
||||
int prefix = ia.getNetworkPrefixLength();
|
||||
if (prefix <= 0 || prefix > 32) {
|
||||
continue;
|
||||
}
|
||||
LocalNet net = new LocalNet(addr.getHostAddress(), prefix, name);
|
||||
if (name.startsWith("wlan")) {
|
||||
return net; // wlan 最优,直接返回
|
||||
}
|
||||
if (fallback == null) {
|
||||
fallback = net;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 本网段定向广播地址,如 192.168.10.255。 */
|
||||
public static String directedBroadcast() {
|
||||
LocalNet net = localNet();
|
||||
return net == null ? null : net.broadcast();
|
||||
}
|
||||
|
||||
/** 广播搜索(自动带上本网段定向广播)。 */
|
||||
public static List<DiscoveryCore.Found> search(Context context, int timeoutMs) {
|
||||
WifiManager.MulticastLock lock = acquireLock(context);
|
||||
try {
|
||||
return DiscoveryCore.search(timeoutMs, directedBroadcast());
|
||||
} finally {
|
||||
releaseLock(lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 子网 TCP 扫描 —— 广播被拦时的兜底。
|
||||
* 注意:会阻塞,必须在后台线程调用。
|
||||
*/
|
||||
public static List<String> scanSubnet(Context context, int port, int connectTimeoutMs) {
|
||||
LocalNet net = localNet();
|
||||
if (net == null || net.prefix > 30) {
|
||||
return new java.util.ArrayList<String>();
|
||||
}
|
||||
WifiManager.MulticastLock lock = acquireLock(context);
|
||||
try {
|
||||
return DiscoveryCore.scanSubnet(net.ip, net.prefix, port,
|
||||
connectTimeoutMs, 300);
|
||||
} finally {
|
||||
releaseLock(lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请电脑打开「网络自检」。
|
||||
* 返回电脑名;没收到回应返回 null。
|
||||
*/
|
||||
public static String requestSelfCheck(Context context, int timeoutMs) {
|
||||
WifiManager.MulticastLock lock = acquireLock(context);
|
||||
try {
|
||||
return DiscoveryCore.requestSelfCheck(directedBroadcast(), timeoutMs);
|
||||
} finally {
|
||||
releaseLock(lock);
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscoveryCore.Found probeHost(String host, int timeoutMs) {
|
||||
return DiscoveryCore.probeHost(host, timeoutMs);
|
||||
}
|
||||
|
||||
public static boolean ping(String host, int port, int timeoutMs) {
|
||||
return DiscoveryCore.ping(host, port, timeoutMs);
|
||||
}
|
||||
|
||||
private static WifiManager.MulticastLock acquireLock(Context context) {
|
||||
try {
|
||||
WifiManager manager = (WifiManager) context.getApplicationContext()
|
||||
.getSystemService(Context.WIFI_SERVICE);
|
||||
if (manager == null) {
|
||||
return null;
|
||||
}
|
||||
WifiManager.MulticastLock lock = manager.createMulticastLock("lanremote");
|
||||
lock.setReferenceCounted(false);
|
||||
lock.acquire();
|
||||
return lock;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void releaseLock(WifiManager.MulticastLock lock) {
|
||||
if (lock == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (lock.isHeld()) {
|
||||
lock.release();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 释放失败不影响结果
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.lanremote.watch;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.WifiManager;
|
||||
|
||||
import java.net.Inet4Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InterfaceAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 安卓侧的自动发现包装。
|
||||
*
|
||||
* 真正干活的是 {@link DiscoveryCore}(纯 Java,可桌面联调)。这里多做两件事:
|
||||
* 1. 抓 WifiManager.MulticastLock(部分手表息屏/省电时会过滤广播)
|
||||
* 2. 算出**本网段的定向广播地址**,这是广播探测能不能收到回复的关键
|
||||
*/
|
||||
public final class Discovery {
|
||||
|
||||
public static final int PORT = DiscoveryCore.PORT;
|
||||
|
||||
private Discovery() {
|
||||
}
|
||||
|
||||
/** 本机在某张网卡上的 IPv4 与前缀长度。 */
|
||||
public static class LocalNet {
|
||||
public final String ip;
|
||||
public final int prefix;
|
||||
public final String iface;
|
||||
|
||||
LocalNet(String ip, int prefix, String iface) {
|
||||
this.ip = ip;
|
||||
this.prefix = prefix;
|
||||
this.iface = iface;
|
||||
}
|
||||
|
||||
public String broadcast() {
|
||||
return DiscoveryCore.directedBroadcast(ip, prefix);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return iface + " " + ip + "/" + prefix;
|
||||
}
|
||||
}
|
||||
|
||||
/** 取当前可用的局域网 IPv4(优先 wlan*)。 */
|
||||
public static LocalNet localNet() {
|
||||
List<LocalNet> all = localNets();
|
||||
return all.isEmpty() ? null : all.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取**所有**可用的局域网 IPv4,wlan 排在最前面。
|
||||
*
|
||||
* <p>为什么不能只取一个:手表可能同时挂着 WiFi 和热点/蓝牙网络,
|
||||
* 一些手表还会多出一张厂商虚拟网卡。原来 {@code localNet()} 拿到第一个
|
||||
* wlan 就 return 了,于是:
|
||||
* * 广播只发到那一个网段,另一个网段上的电脑永远搜不到
|
||||
* * 子网扫描也只扫那一个网段
|
||||
* 用户看到的就是"同一个 WiFi 却搜不到"。
|
||||
*/
|
||||
public static List<LocalNet> localNets() {
|
||||
List<LocalNet> wlan = new java.util.ArrayList<LocalNet>();
|
||||
List<LocalNet> others = new java.util.ArrayList<LocalNet>();
|
||||
try {
|
||||
for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
|
||||
if (!ni.isUp() || ni.isLoopback()) {
|
||||
continue;
|
||||
}
|
||||
String name = ni.getName() == null ? "" : ni.getName();
|
||||
if (name.startsWith("p2p") || name.startsWith("dummy")) {
|
||||
continue; // WiFi Direct / 占位网卡
|
||||
}
|
||||
for (InterfaceAddress ia : ni.getInterfaceAddresses()) {
|
||||
InetAddress addr = ia.getAddress();
|
||||
if (!(addr instanceof Inet4Address) || addr.isLoopbackAddress()) {
|
||||
continue;
|
||||
}
|
||||
int prefix = ia.getNetworkPrefixLength();
|
||||
if (prefix <= 0 || prefix > 32) {
|
||||
continue;
|
||||
}
|
||||
String host = addr.getHostAddress();
|
||||
if (host == null || host.startsWith("169.254.")) {
|
||||
continue; // APIPA 链路地址没用
|
||||
}
|
||||
LocalNet net = new LocalNet(host, prefix, name);
|
||||
if (name.startsWith("wlan")) {
|
||||
wlan.add(net);
|
||||
} else {
|
||||
others.add(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 权限不足 / 网卡枚举失败:返回已经拿到的
|
||||
}
|
||||
List<LocalNet> all = new java.util.ArrayList<LocalNet>();
|
||||
all.addAll(wlan);
|
||||
all.addAll(others);
|
||||
return all;
|
||||
}
|
||||
|
||||
/** 本机在局域网里的 IPv4 列表(去重)。 */
|
||||
public static List<String> localIpv4() {
|
||||
List<String> out = new java.util.ArrayList<String>();
|
||||
for (LocalNet net : localNets()) {
|
||||
if (!out.contains(net.ip)) {
|
||||
out.add(net.ip);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 本网段定向广播地址,如 192.168.10.255。 */
|
||||
public static String directedBroadcast() {
|
||||
LocalNet net = localNet();
|
||||
return net == null ? null : net.broadcast();
|
||||
}
|
||||
|
||||
/** **所有**网段的定向广播地址(多网卡时都要探)。 */
|
||||
public static List<String> directedBroadcasts() {
|
||||
List<String> out = new java.util.ArrayList<String>();
|
||||
for (LocalNet net : localNets()) {
|
||||
String b = net.broadcast();
|
||||
if (b != null && !b.equals(net.ip) && !out.contains(b)) {
|
||||
out.add(b);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 广播搜索(自动带上**所有**本网段定向广播 + 重发)。 */
|
||||
public static List<DiscoveryCore.Found> search(Context context, int timeoutMs) {
|
||||
WifiManager.MulticastLock lock = acquireLock(context);
|
||||
try {
|
||||
return DiscoveryCore.searchMany(timeoutMs, directedBroadcasts());
|
||||
} finally {
|
||||
releaseLock(lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 子网 TCP 扫描 —— 广播被拦时的兜底。
|
||||
* 注意:会阻塞,必须在后台线程调用。
|
||||
*/
|
||||
public static List<String> scanSubnet(Context context, int port, int connectTimeoutMs) {
|
||||
WifiManager.MulticastLock lock = acquireLock(context);
|
||||
try {
|
||||
List<String> open = new java.util.ArrayList<String>();
|
||||
// 每个网段都扫一遍;/30 及更小的网段没有可用主机,跳过。
|
||||
for (LocalNet net : localNets()) {
|
||||
if (net.prefix > 30) {
|
||||
continue;
|
||||
}
|
||||
for (String host : DiscoveryCore.scanSubnet(net.ip, net.prefix,
|
||||
port, connectTimeoutMs, 300)) {
|
||||
if (!open.contains(host)) {
|
||||
open.add(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
return open;
|
||||
} finally {
|
||||
releaseLock(lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请电脑打开「网络自检」。
|
||||
* 返回电脑名;没收到回应返回 null。
|
||||
*/
|
||||
public static String requestSelfCheck(Context context, int timeoutMs) {
|
||||
WifiManager.MulticastLock lock = acquireLock(context);
|
||||
try {
|
||||
return DiscoveryCore.requestSelfCheck(directedBroadcast(), timeoutMs);
|
||||
} finally {
|
||||
releaseLock(lock);
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscoveryCore.Found probeHost(String host, int timeoutMs) {
|
||||
return DiscoveryCore.probeHost(host, timeoutMs);
|
||||
}
|
||||
|
||||
public static boolean ping(String host, int port, int timeoutMs) {
|
||||
return DiscoveryCore.ping(host, port, timeoutMs);
|
||||
}
|
||||
|
||||
private static WifiManager.MulticastLock acquireLock(Context context) {
|
||||
try {
|
||||
WifiManager manager = (WifiManager) context.getApplicationContext()
|
||||
.getSystemService(Context.WIFI_SERVICE);
|
||||
if (manager == null) {
|
||||
return null;
|
||||
}
|
||||
WifiManager.MulticastLock lock = manager.createMulticastLock("lanremote");
|
||||
lock.setReferenceCounted(false);
|
||||
lock.acquire();
|
||||
return lock;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void releaseLock(WifiManager.MulticastLock lock) {
|
||||
if (lock == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (lock.isHeld()) {
|
||||
lock.release();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 释放失败不影响结果
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,19 +79,23 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
|
||||
/** 中转服务器地址(跨网络用)。空 = 只走局域网。 */
|
||||
/**
|
||||
* 内置的默认中转地址。
|
||||
* 内置的默认中转地址 —— **默认留空**,由用户自己在「中转设置」里填。
|
||||
*
|
||||
* 服务器端已经架在随身 WiFi 上并用 Cloudflare 隧道暴露成
|
||||
* relay.xxmxxs.top,所以手表装完不用填任何地址就能连。
|
||||
* 用户在「中转设置」里随时能改。
|
||||
* <p>为什么不再内置一个:原来写死了一个公网隧道域名,结果
|
||||
* 用户的部署/域名一变,写死的地址就成了错的,App 装完就一直在
|
||||
* 连一个不存在的主机。留空反而干净。
|
||||
*
|
||||
* <p>留空**不影响局域网**:广播发现、填 IP 直连、首页已配对电脑
|
||||
* 一键连,全都不依赖这个值。
|
||||
*/
|
||||
private static final String DEFAULT_RELAY_URL = "https://relay.xxmxxs.top";
|
||||
private static final String DEFAULT_RELAY_URL = "";
|
||||
|
||||
private String relayUrl() {
|
||||
String saved = getSharedPreferences("lanremote", MODE_PRIVATE)
|
||||
.getString("relay_url", DEFAULT_RELAY_URL);
|
||||
// 没设过(或设成了空)就用内置默认值 —— 开箱即用,
|
||||
// 装完 App 不用填任何地址就能连。
|
||||
// 用户没填过就返回内置默认值(现在是空字符串 = 只走局域网)。
|
||||
// 注意:这里返回空是**正常状态**,不是错误 ——
|
||||
// 调用方都用 isEmpty() 判断要不要走中转,空了就老老实实走局域网。
|
||||
return (saved == null || saved.trim().isEmpty())
|
||||
? DEFAULT_RELAY_URL : saved.trim();
|
||||
}
|
||||
@@ -279,6 +283,12 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
destroyed = true;
|
||||
try {
|
||||
ui.removeCallbacksAndMessages(null);
|
||||
} catch (Exception ignored) {
|
||||
// 主线程 Handler 拿不到也不致命
|
||||
}
|
||||
try {
|
||||
client.close();
|
||||
} catch (Exception ignored) {
|
||||
@@ -2390,7 +2400,11 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
}
|
||||
|
||||
/**
|
||||
* host 是不是和手表在同一个 /24 网段。
|
||||
* host 是不是和手表在同一个子网。
|
||||
*
|
||||
* <p>用**手表网卡的真实掩码**判断,而不是写死 /24 ——
|
||||
* 在 10.x/8、172.16/12 或任何 /16 网络上,/24 比较会把
|
||||
* "其实同一个网络"误判成"不同网络",于是明明能直连却走了中转。
|
||||
*
|
||||
* <p>读不到手表自己的网卡时返回 true(保守:宁可按原地址试一次,
|
||||
* 也不要因为拿不到权限就误判成"过期",那会让本可以直连的场景
|
||||
@@ -2400,18 +2414,33 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
if (host == null || host.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
java.util.List<String> mine = localIpv4List();
|
||||
java.util.List<Discovery.LocalNet> mine = Discovery.localNets();
|
||||
if (mine.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (String myIp : mine) {
|
||||
if (sameSubnet(host, myIp)) {
|
||||
for (Discovery.LocalNet net : mine) {
|
||||
if (inSameSubnet(host, net.ip, net.prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按真实掩码比较两个地址是否同子网。
|
||||
*
|
||||
* <p>掩码拿不到(<=0)时退回老的 /24 比较。
|
||||
*/
|
||||
private boolean inSameSubnet(String a, String b, int prefix) {
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
if (prefix <= 0 || prefix > 32) {
|
||||
return sameSubnet(a, b);
|
||||
}
|
||||
return DiscoveryCore.sameSubnet(a, b, prefix);
|
||||
}
|
||||
|
||||
/** 手表自己在局域网里的 IPv4 地址(可能多张网卡)。拿不到就空。 */
|
||||
private java.util.List<String> localIpv4List() {
|
||||
java.util.List<String> found = new java.util.ArrayList<String>();
|
||||
@@ -2457,12 +2486,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
&& left[2].equals(right[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从电脑报上来的局域网地址里,挑一个和手表同网段的。
|
||||
*
|
||||
* 挑不到就返回 null —— 表示不在同一个网络,继续走中转。
|
||||
*/
|
||||
/** 从电脑报来的 IP 里挑一个能用的(不限网段)。 */
|
||||
/** 从电脑报来的 IP 里挑一个能用的(不限网段,用于刷新已配对记录)。 */
|
||||
private String pickAnyLanIp(java.util.List<String> deviceIps) {
|
||||
if (deviceIps == null) {
|
||||
return null;
|
||||
@@ -2475,16 +2499,36 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从电脑报上来的局域网地址里,挑一个和手表同网段的。
|
||||
*
|
||||
* <p>用真实掩码判断(见 {@link #inSameSubnet}),
|
||||
* 不再假设 /24 —— 否则 /16 网络上永远挑不中,白白走中转。
|
||||
*
|
||||
* <p>挑不到就返回 null —— 表示不在同一个网络,继续走中转。
|
||||
*/
|
||||
private String pickSameSubnet(java.util.List<String> deviceIps) {
|
||||
if (deviceIps == null || deviceIps.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
java.util.List<String> mine = localIpv4List();
|
||||
if (mine.isEmpty()) {
|
||||
java.util.List<Discovery.LocalNet> mine = Discovery.localNets();
|
||||
if (!mine.isEmpty()) {
|
||||
for (String deviceIp : deviceIps) {
|
||||
for (Discovery.LocalNet net : mine) {
|
||||
if (inSameSubnet(deviceIp, net.ip, net.prefix)) {
|
||||
return deviceIp;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 拿不到网卡掩码:退回老的 /24 比较(总比完全不判断强)
|
||||
java.util.List<String> plain = localIpv4List();
|
||||
if (plain.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (String deviceIp : deviceIps) {
|
||||
for (String myIp : mine) {
|
||||
for (String myIp : plain) {
|
||||
if (sameSubnet(deviceIp, myIp)) {
|
||||
return deviceIp;
|
||||
}
|
||||
@@ -2496,6 +2540,26 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
// ---- 断线自动切换用的状态 ----
|
||||
/** 当前是不是走局域网直连(断了要切回中转)。 */
|
||||
private volatile boolean lanDirect;
|
||||
|
||||
/**
|
||||
* 走中转时,隔多久回头看看局域网能不能用。
|
||||
*
|
||||
* <p>为什么要回头:原来只在"刚经中转连上"的那一刻试一次直连。
|
||||
* 之后用户从外面走回家、或者电脑换了网络又换回来,
|
||||
* 就一直挂在中转上 —— 白白费流量、还慢。
|
||||
* 现在会定期重试,能直连就立刻切回。
|
||||
*
|
||||
* <p>起手 15 秒,连续失败就翻倍到最多 2 分钟,
|
||||
* 成功切到局域网后归零。每次只是一个 TCP 连接尝试,开销很小。
|
||||
*/
|
||||
private static final long LAN_RECHECK_MIN_MS = 15000L;
|
||||
private static final long LAN_RECHECK_MAX_MS = 120000L;
|
||||
private long lanRecheckDelay = LAN_RECHECK_MIN_MS;
|
||||
/** 防重入:已经排了一次就不再排。 */
|
||||
private volatile boolean lanRecheckScheduled;
|
||||
/** Activity 已销毁,定时器不要再跑。 */
|
||||
private volatile boolean destroyed;
|
||||
|
||||
/** 刚才连的地址/配对码/令牌,切回中转时要用。 */
|
||||
private volatile String lastHost = "";
|
||||
private volatile int lastPort;
|
||||
@@ -2713,6 +2777,14 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
// 局域网断了要自动切回中转,靠的就是这个标记。
|
||||
lanDirect = finalLanDirect;
|
||||
autoRetry = 0;
|
||||
if (finalLanDirect) {
|
||||
// 已经直连上了,不用再找
|
||||
lanRecheckDelay = LAN_RECHECK_MIN_MS;
|
||||
} else {
|
||||
// 这次走的是中转 —— 过一会儿回头看看能不能改直连
|
||||
lanRecheckDelay = LAN_RECHECK_MIN_MS;
|
||||
scheduleLanRecheck();
|
||||
}
|
||||
if (serverLabel != null) {
|
||||
serverLabel.setText("已连接 "
|
||||
+ (finalName == null ? host : finalName)
|
||||
@@ -2930,6 +3002,79 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 排一次"能不能改回局域网"的检查。
|
||||
*
|
||||
* <p>只在当前走中转时才有意义,而且会防重入 ——
|
||||
* 连接成功、切中转成功、检查失败三处都会调它,
|
||||
* 不加锁会排出好几个定时器。
|
||||
*/
|
||||
private void scheduleLanRecheck() {
|
||||
if (destroyed || lanRecheckScheduled) {
|
||||
return;
|
||||
}
|
||||
if (relayUrl().isEmpty()) {
|
||||
return; // 没配中转,本来就不该在这儿
|
||||
}
|
||||
lanRecheckScheduled = true;
|
||||
final long delay = lanRecheckDelay;
|
||||
ui.postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
lanRecheckScheduled = false;
|
||||
if (!destroyed) {
|
||||
tryLanReconnect();
|
||||
}
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 现在走的是中转 —— 看看局域网能不能用,能就切回直连。
|
||||
*
|
||||
* <p>先做一个**便宜的 TCP 探测**再动真格:
|
||||
* {@code connect(preferLan=true)} 会先把中转连接关掉,
|
||||
* 万一直连握手失败就得再切回中转,中间有个空窗。
|
||||
* 先 ping 一次能把这个空窗压到最小 —— ping 不通就什么都不做,
|
||||
* 保持中转连接完好吗,下轮再看。
|
||||
*/
|
||||
private void tryLanReconnect() {
|
||||
if (destroyed || lanDirect || busy || autoSwitching || !client.isConnected()) {
|
||||
return;
|
||||
}
|
||||
// 候选地址:优先用中转刚报回来的最新地址,其次用上次直连用的地址
|
||||
String ip = pickSameSubnet(client.relayLanIps());
|
||||
if (ip == null && lastHost != null && !lastHost.isEmpty()) {
|
||||
ip = pickSameSubnet(java.util.Collections.singletonList(lastHost));
|
||||
}
|
||||
int port = client.relayLanPort() > 0 ? client.relayLanPort() : lastPort;
|
||||
if (ip == null || port <= 0) {
|
||||
// 不在同一网段 —— 局域网这条路本来就不通,隔久一点再看
|
||||
lanRecheckDelay = Math.min(LAN_RECHECK_MAX_MS, lanRecheckDelay * 2);
|
||||
scheduleLanRecheck();
|
||||
return;
|
||||
}
|
||||
|
||||
boolean reachable;
|
||||
try {
|
||||
reachable = DiscoveryCore.ping(ip, port, 2000);
|
||||
} catch (Throwable error) {
|
||||
reachable = false;
|
||||
}
|
||||
if (!reachable) {
|
||||
lanRecheckDelay = Math.min(LAN_RECHECK_MAX_MS, lanRecheckDelay * 2);
|
||||
scheduleLanRecheck();
|
||||
return;
|
||||
}
|
||||
|
||||
// 局域网通了 —— 切过去。
|
||||
// preferLan=true:先直连,万一握手失败会自动退回中转,不会让用户断线。
|
||||
appendLog("检测到局域网可用(" + ip + ":" + port + "),切回直连");
|
||||
lanRecheckDelay = LAN_RECHECK_MIN_MS;
|
||||
final String key = DeviceStore.deviceKey(ip, port, store.deviceId());
|
||||
connect(ip, port, lastCode, lastToken, key, true);
|
||||
}
|
||||
|
||||
private void switchToRelay() {
|
||||
final String relay = relayUrl();
|
||||
if (relay.isEmpty()) {
|
||||
@@ -2971,6 +3116,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
autoSwitching = false;
|
||||
if (success) {
|
||||
lanDirect = false;
|
||||
// 中转通了 —— 但局域网可能只是刚才抖了一下。
|
||||
// 排一次回头检查,能直连就切回去(省流量、更快)。
|
||||
lanRecheckDelay = LAN_RECHECK_MIN_MS;
|
||||
scheduleLanRecheck();
|
||||
lastToken = finalToken;
|
||||
if (finalToken != null && !finalToken.isEmpty()) {
|
||||
store.saveDevice(
|
||||
|
||||
Reference in New Issue
Block a user