package com.lanremote.watch; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.SocketTimeoutException; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; /** * 局域网自动发现的**纯 Java 核心**(不含任何 Android 依赖)。 * * 两层查找策略: * 1. UDP 广播探测(快,前提是广播没被拦) * 2. 定向广播 + 子网 TCP 扫描(慢一点,但广播被拦时仍然管用) * * 与 Windows 端 lanremote/discovery.py 用同一套报文格式。 */ public final class DiscoveryCore { public static final int PORT = 8766; private static final String PROBE = "{\"lanremote\":\"LANREMOTE_DISCOVER\",\"proto\":1}"; private static final String REPLY_MAGIC = "LANREMOTE_REPLY"; private static final String SELFCHECK_MAGIC = "LANREMOTE_SELFCHECK"; private static final String ACK_MAGIC = "LANREMOTE_ACK"; private DiscoveryCore() { } public static class Found { public final String host; public final int port; public final String name; public final String version; /** 来源:broadcast(广播) / directed(定向广播) / scan(端口扫描) */ public final String via; public Found(String host, int port, String name, String version, String via) { this.host = host; this.port = port; this.name = name; this.version = version; this.via = via; } @Override public String toString() { return (name == null || name.isEmpty() ? host : name) + " " + host + ":" + port + " [" + via + "]"; } } // ================================================================== // 地址计算(纯函数,可单独验证) // ================================================================== public static long ipToLong(String ip) { String[] parts = ip.split("\\."); if (parts.length != 4) { return -1; } long value = 0; for (String part : parts) { int octet; try { octet = Integer.parseInt(part.trim()); } catch (NumberFormatException e) { return -1; } if (octet < 0 || octet > 255) { return -1; } value = (value << 8) | octet; } return value & 0xFFFFFFFFL; } public static String longToIp(long value) { return ((value >> 24) & 0xFF) + "." + ((value >> 16) & 0xFF) + "." + ((value >> 8) & 0xFF) + "." + (value & 0xFF); } /** * 由本机 IP + 前缀长度算出**定向广播地址**。 * * 例:192.168.10.104/24 -> 192.168.10.255 * * 之前这里是个 bug:代码里写死了猜 192.168.255.255, * 对 /24 网段来说那根本不是本网段的广播地址, * 很多路由器和安卓版本不会转发它,导致广播探测收不到任何回复。 */ public static String directedBroadcast(String ip, int prefix) { long addr = ipToLong(ip); if (addr < 0 || prefix < 0 || prefix > 32) { return null; } if (prefix == 32) { return ip; // 单机,没有广播 } long mask = (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL; long network = addr & mask; long broadcast = (network | (~mask & 0xFFFFFFFFL)) & 0xFFFFFFFFL; return longToIp(broadcast); } /** 列出该网段内所有可分配主机地址(跳过网络号与广播号,最多 maxHosts 个)。 */ public static List subnetHosts(String ip, int prefix, int maxHosts) { List hosts = new ArrayList(); long addr = ipToLong(ip); if (addr < 0 || prefix < 0 || prefix > 30) { return hosts; } long mask = (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL; long network = addr & mask; long broadcast = (network | (~mask & 0xFFFFFFFFL)) & 0xFFFFFFFFL; for (long host = network + 1; host < broadcast && hosts.size() < maxHosts; host++) { hosts.add(longToIp(host)); } return hosts; } /** * 按**真实前缀长度**判断两个 IPv4 是不是同一个子网。 * *

为什么需要这个:原来手表里是写死比较前三段(等于假设 /24)。 * 在 10.x/8、172.16/12,或者任何配成 /16 的网络(校园网、部分路由器)上: *

     *     手表 10.8.1.5   电脑 10.8.2.5   (掩码 /16,其实同一个网络)
     * 
* 前三段不同 -> 被判定成"不在同一网络" -> **明明能直连却走了中转**。 * 用户看到的是"同一个 WiFi,却一直在走中转、还费流量"。 * *

用真实掩码比较之后,这类网络也能正确识别成同网段。 * * @param prefix 前缀长度;非法(<=0 或 >32)时退回 /24 比较 */ public static boolean sameSubnet(String a, String b, int prefix) { long left = ipToLong(a); long right = ipToLong(b); if (left < 0 || right < 0) { return false; } if (prefix <= 0 || prefix > 32) { return sameSubnet24(a, b); } long mask = (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL; return (left & mask) == (right & mask); } /** * 老的 /24 比较(只看前三段)。 * *

保留下来是当**兜底**:拿不到本机网卡掩码时用它; * 家里绝大多数 /24 网络下,它和真实掩码的结果一致。 */ public static boolean sameSubnet24(String a, String b) { if (a == null || b == null) { return false; } String[] left = a.split("\\."); String[] right = b.split("\\."); if (left.length != 4 || right.length != 4) { return false; } return left[0].equals(right[0]) && left[1].equals(right[1]) && left[2].equals(right[2]); } // ================================================================== // 查找 // ================================================================== public static List search(int timeoutMs) { return search(timeoutMs, null); } /** * 探测包**重发的时间点**(毫秒,相对开始时刻)。 * *

为什么要重发:UDP 广播是"尽力而为",丢一个包就整轮搜不到。 * 原来这里只在 t=0 发一次,然后干等 4 秒 —— * 实测在 WiFi 信道拥挤、或电脑刚开机应答线程还没起来时, * 那唯一一个包丢了就是"搜不到电脑",用户只会以为软件坏了。 * *

{@link #requestSelfCheck} 早就用了"连发三轮"的做法, * 这里保持一致。多发的几个包成本可以忽略(每个才几十字节), * 但把"漏发现"的概率从"一次丢包"降到"连续丢包"。 */ private static final int[] PROBE_AT_MS = {0, 350, 900, 1800, 3000}; /** * 广播搜索。会阻塞 timeoutMs 毫秒,必须在后台线程调用。 * * @param extraBroadcast 定向广播地址,如 192.168.10.255(可为 null) */ public static List search(int timeoutMs, String extraBroadcast) { List extra = new ArrayList(); if (extraBroadcast != null && extraBroadcast.length() > 0) { extra.add(extraBroadcast); } return searchMany(timeoutMs, extra); } /** * 广播搜索(可同时探测多个网段)。 * *

手表可能同时开着 WiFi 和热点/蓝牙网络,电脑也可能有多张网卡。 * 只探一个网段会出现"明明同一个网络却搜不到"。 * * @param extraBroadcasts 多个定向广播地址,如 * ["192.168.10.255", "192.168.43.255"] */ public static List searchMany(int timeoutMs, List extraBroadcasts) { List results = new ArrayList(); Set seen = new LinkedHashSet(); DatagramSocket socket = null; try { socket = new DatagramSocket(); socket.setBroadcast(true); // 收包超时给短一点(250ms),这样"到点该重发"能及时被执行; // 整体等待时间仍由 deadline 控制。 socket.setSoTimeout(250); byte[] payload = PROBE.getBytes("UTF-8"); List targets = new ArrayList(); addTarget(targets, "255.255.255.255"); // 受限广播 if (extraBroadcasts != null) { for (String one : extraBroadcasts) { addTarget(targets, one); // 各网段定向广播(关键) } } if (targets.isEmpty()) { return results; } long start = System.currentTimeMillis(); long deadline = start + timeoutMs; int nextProbe = 0; byte[] buffer = new byte[2048]; while (System.currentTimeMillis() < deadline) { // ---- 到点就重发一轮探测 ---- long elapsed = System.currentTimeMillis() - start; while (nextProbe < PROBE_AT_MS.length && elapsed >= PROBE_AT_MS[nextProbe]) { for (InetAddress target : targets) { try { socket.send(new DatagramPacket(payload, payload.length, target, PORT)); } catch (Exception ignored) { // 某个广播地址不被允许时跳过,不影响其他目标 } } nextProbe++; } // ---- 收一个包 ---- DatagramPacket packet = new DatagramPacket(buffer, buffer.length); try { socket.receive(packet); } catch (SocketTimeoutException timeout) { continue; } catch (Exception broken) { break; } Found found = parse(packet.getData(), packet.getLength(), packet.getAddress().getHostAddress(), "broadcast"); if (found == null) { continue; } String key = found.host + ":" + found.port; if (seen.add(key)) { results.add(found); } } } catch (Exception ignored) { // 网络不可用时返回已收集到的结果 } finally { if (socket != null) { socket.close(); } } return results; } /** 把一个广播地址字符串加入目标列表(非法地址忽略)。 */ private static void addTarget(List targets, String address) { if (address == null || address.length() == 0) { return; } try { InetAddress parsed = InetAddress.getByName(address); if (!targets.contains(parsed)) { targets.add(parsed); } } catch (Exception ignored) { // 解析不了就跳过 } } /** * 子网 TCP 端口扫描。 * * 用途:广播被路由器/防火墙拦掉时,这是唯一还能找到被控端的办法。 * 直接对本网段 254 个地址并发试连目标端口。 */ public static List scanSubnet(String ip, int prefix, int port, int connectTimeoutMs, int maxHosts) { List hosts = subnetHosts(ip, prefix, maxHosts); List open = Collections.synchronizedList(new ArrayList()); if (hosts.isEmpty()) { return open; } int threads = Math.min(48, Math.max(8, hosts.size())); ExecutorService pool = Executors.newFixedThreadPool(threads); List> tasks = new ArrayList>(); try { for (final String host : hosts) { tasks.add(pool.submit(new Callable() { @Override public Void call() { if (ping(host, port, connectTimeoutMs)) { open.add(host); } return null; } })); } for (Future task : tasks) { try { task.get(connectTimeoutMs + 1500L, TimeUnit.MILLISECONDS); } catch (Exception ignored) { // 单个地址超时不影响整体 } } } finally { pool.shutdownNow(); } Collections.sort(open, new java.util.Comparator() { @Override public int compare(String a, String b) { long la = ipToLong(a); long lb = ipToLong(b); return la < lb ? -1 : (la > lb ? 1 : 0); } }); return open; } /** * 请电脑打开「网络自检」。 * * 走 UDP 广播是有意的:连不上的典型原因就是 TCP 被防火墙拦了, * 而 UDP 广播往往仍然通(那正是「能搜到但连不上」的原因)。 * 所以只要能广播出去,就能让电脑把自检窗口弹出来。 * * @return 电脑名;没有回应返回 null */ public static String requestSelfCheck(String extraBroadcast, int timeoutMs) { DatagramSocket socket = null; try { socket = new DatagramSocket(); socket.setBroadcast(true); socket.setSoTimeout(500); byte[] payload = ("{\"lanremote\":\"" + SELFCHECK_MAGIC + "\",\"proto\":1}").getBytes("UTF-8"); List targets = new ArrayList(); targets.add("255.255.255.255"); if (extraBroadcast != null && extraBroadcast.length() > 0) { targets.add(extraBroadcast); } // 连发三轮,丢一两个包也能到 for (int round = 0; round < 3; round++) { for (String target : targets) { try { socket.send(new DatagramPacket(payload, payload.length, InetAddress.getByName(target), PORT)); } catch (Exception ignored) { // 某个广播地址不被允许时跳过 } } try { Thread.sleep(120); } catch (InterruptedException stop) { Thread.currentThread().interrupt(); break; } } long deadline = System.currentTimeMillis() + timeoutMs; byte[] buffer = new byte[2048]; while (System.currentTimeMillis() < deadline) { DatagramPacket packet = new DatagramPacket(buffer, buffer.length); try { socket.receive(packet); } catch (SocketTimeoutException timeout) { continue; } catch (Exception broken) { break; } String text = new String(packet.getData(), 0, packet.getLength(), "UTF-8"); if (text.indexOf(ACK_MAGIC) < 0) { continue; } try { Map obj = Json.parseObject(text); String name = Json.str(obj, "name", ""); String note = Json.str(obj, "note", ""); if (name != null && name.length() > 0) { // 电脑那边现在会把诊断结论一起回过来(它自己不弹窗口), // 用换行分隔:第一行是电脑名,后面是结论。 return note != null && note.length() > 0 ? name + "\n" + note : name; } } catch (Exception ignored) { // 解析失败就用来源 IP } return packet.getAddress().getHostAddress(); } } catch (Exception ignored) { // 网络不可用 } finally { if (socket != null) { socket.close(); } } return null; } /** 对扫描到的地址做一次单播发现,拿到电脑名和真实端口。 */ public static Found probeHost(String host, int timeoutMs) { DatagramSocket socket = null; try { socket = new DatagramSocket(); socket.setSoTimeout(Math.max(300, timeoutMs)); byte[] payload = PROBE.getBytes("UTF-8"); InetAddress target = InetAddress.getByName(host); socket.send(new DatagramPacket(payload, payload.length, target, PORT)); byte[] buffer = new byte[2048]; DatagramPacket packet = new DatagramPacket(buffer, buffer.length); socket.receive(packet); return parse(packet.getData(), packet.getLength(), packet.getAddress().getHostAddress(), "unicast"); } catch (Exception e) { return null; } finally { if (socket != null) { socket.close(); } } } /** TCP 探测结果 —— 关键是区分「拒绝」和「超时」,两者含义完全不同。 */ public static class PingResult { public final boolean ok; public final String reason; // ok / refused / timeout / unreachable / other public final long ms; public PingResult(boolean ok, String reason, long ms) { this.ok = ok; this.reason = reason; this.ms = ms; } /** 给人看的解释。 */ public String describe() { if (ok) { return "成功(" + ms + " ms)"; } if ("refused".equals(reason)) { return "被拒绝(" + ms + " ms)—— 主机在线,但**没有程序在监听这个端口**"; } if ("timeout".equals(reason)) { return "超时(" + ms + " ms)—— 数据包没有到达或被丢弃" + "(防火墙拦截 / 不在同一网络 / 主机离线)"; } if ("unreachable".equals(reason)) { return "网络不可达(" + ms + " ms)—— 不同网段或没有路由"; } return "失败:" + reason + "(" + ms + " ms)"; } } /** * 带原因区分的 TCP 探测。 * * 为什么必须区分: * * 被拒绝(RST) = 包到了对方,对方说"这个端口没人听" -> 被控端没运行 * * 超时 = 包根本没到,或中途被丢 -> 防火墙 / 不同网络 / 对方离线 * 这两种情况的排查方向完全相反。 */ public static PingResult pingDetailed(String host, int port, int timeoutMs) { long start = System.currentTimeMillis(); java.net.Socket socket = new java.net.Socket(); try { socket.connect(new InetSocketAddress(host, port), timeoutMs); return new PingResult(true, "ok", System.currentTimeMillis() - start); } catch (java.net.SocketTimeoutException e) { return new PingResult(false, "timeout", System.currentTimeMillis() - start); } catch (java.net.NoRouteToHostException e) { return new PingResult(false, "unreachable", System.currentTimeMillis() - start); } catch (java.net.ConnectException e) { String msg = String.valueOf(e.getMessage()).toLowerCase(); String reason = msg.contains("refused") || msg.contains("拒绝") ? "refused" : "unreachable"; return new PingResult(false, reason, System.currentTimeMillis() - start); } catch (Exception e) { return new PingResult(false, e.getClass().getSimpleName(), System.currentTimeMillis() - start); } finally { try { socket.close(); } catch (Exception ignored) { // 忽略关闭异常 } } } /** 简单版:只要通不通。 */ public static boolean ping(String host, int port, int timeoutMs) { return pingDetailed(host, port, timeoutMs).ok; } static Found parse(byte[] data, int length, String fromHost, String via) { try { String text = new String(data, 0, length, "UTF-8"); if (text.indexOf(REPLY_MAGIC) < 0) { return null; } Map obj = Json.parseObject(text); if (!REPLY_MAGIC.equals(Json.str(obj, "lanremote", ""))) { return null; } int port = Json.integer(obj, "port", 0); if (port <= 0 || port > 65535) { return null; } return new Found(fromHost, port, Json.str(obj, "name", ""), Json.str(obj, "version", ""), via); } catch (Exception e) { return null; } } }