ykxmz 1.5.0锛氫慨澶嶆墜琛ㄧ TLS 璇佷功鏍¢獙 + MTU 淇缃戠粶绋冲畾鎬?

琚帶绔?- 淇 MTU 闂锛氬す MSS + 璺緞 MTU 鎺㈡祴锛堝叕缃戝彲鐢ㄧ巼 48% -> 100%锛?- 澶嶇敤 HTTP 杩炴帴锛堝厤鎺夋瘡娆℃柊寤?TLS 鐨?30% 鎻℃墜澶辫触锛?- 姣忓垎閽熷洖澶存壘灞€鍩熺綉锛岃嚜鍔ㄥ垏鍥炲揩 400 鍊嶇殑璺緞
- 鐢婚潰蹇冭烦甯э細闈欐鏃剁渷娴侀噺锛屽鎴风浠嶇煡閬撴祦鏄椿鐨?- 閿佸睆鏃剁粰鍑哄彲鎿嶄綔鐨勬彁绀?
鎵嬭〃绔?- 鍐呯疆 GTS Root R4 / GlobalSign Root CA锛屼慨 SSLHandshakeException
- 杩炴帴閿欒缈昏瘧鎴愪汉璇濓紙璇佷功/鏃堕棿/瑙f瀽/瓒呮椂鍒嗗埆鎻愮ず锛?- 缁忎腑杞繛涓婂悗鐢ㄦ湇鍔″櫒鎶ョ殑鍦板潃鍒锋柊宸查厤瀵硅褰?- 鏂板灞€鍩熺綉杩炴帴椤甸潰銆佹湇鍔″櫒寤惰繜妫€娴?
鏈嶅姟鍣ㄧ
- 鏀寔 chunked 璇锋眰浣擄紙淇瘡 16 绉掍竴娆$殑杩炴帴閿欎贡涓庡ぇ闈㈢Н 502锛?- 缃戠粶璋冧紭鑴氭湰 net-tune.sh锛堝紑鏈鸿嚜鍔ㄥ簲鐢級
- 绠$悊椤碉細鎵归噺鍒犻櫎銆佺綉缁滀綋妫€銆佷笅杞藉尯
- 闅ч亾鏀?HTTP/2 + 鍙岃繛鎺?
鏂囨。
- 璁稿彲鍗忚 / 瀹夎鍓嶅繀璇?/ 瀹夎鍚庨』鐭ワ紙浣滆€咃細灏忛害瀛愶級
This commit is contained in:
xmz7891
2026-10-06 19:01:05 +08:00
commit b705216e4a
3195 changed files with 203292 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
package com.lanremote.watch;
/**
* 自带的 Base64 实现。
*
* 不用 android.util.Base64(桌面 JVM 上没有),也不用 java.util.Base64
* (Android 8.0 / API 26 才提供,手表可能更老)。
* 这样整个协议层保持纯 Java,可在桌面直接联调。
*/
public final class B64 {
private static final char[] ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();
private static final int[] LOOKUP = new int[128];
static {
for (int i = 0; i < LOOKUP.length; i++) {
LOOKUP[i] = -1;
}
for (int i = 0; i < ALPHABET.length; i++) {
LOOKUP[ALPHABET[i]] = i;
}
}
private B64() {
}
public static String encode(byte[] data) {
if (data == null) {
return "";
}
StringBuilder sb = new StringBuilder(((data.length + 2) / 3) * 4);
int i = 0;
while (i + 2 < data.length) {
int triple = ((data[i] & 0xFF) << 16) | ((data[i + 1] & 0xFF) << 8) | (data[i + 2] & 0xFF);
sb.append(ALPHABET[(triple >>> 18) & 0x3F]);
sb.append(ALPHABET[(triple >>> 12) & 0x3F]);
sb.append(ALPHABET[(triple >>> 6) & 0x3F]);
sb.append(ALPHABET[triple & 0x3F]);
i += 3;
}
int remaining = data.length - i;
if (remaining == 1) {
int chunk = (data[i] & 0xFF) << 16;
sb.append(ALPHABET[(chunk >>> 18) & 0x3F]);
sb.append(ALPHABET[(chunk >>> 12) & 0x3F]);
sb.append("==");
} else if (remaining == 2) {
int chunk = ((data[i] & 0xFF) << 16) | ((data[i + 1] & 0xFF) << 8);
sb.append(ALPHABET[(chunk >>> 18) & 0x3F]);
sb.append(ALPHABET[(chunk >>> 12) & 0x3F]);
sb.append(ALPHABET[(chunk >>> 6) & 0x3F]);
sb.append('=');
}
return sb.toString();
}
public static byte[] decode(String text) {
if (text == null || text.isEmpty()) {
return new byte[0];
}
int length = text.length();
while (length > 0 && text.charAt(length - 1) == '=') {
length--;
}
int outLength = (length * 3) / 4;
byte[] out = new byte[outLength];
int buffer = 0;
int bits = 0;
int index = 0;
for (int i = 0; i < length; i++) {
char c = text.charAt(i);
if (c >= 128) {
continue;
}
int value = LOOKUP[c];
if (value < 0) {
continue; // 跳过换行/空白等
}
buffer = (buffer << 6) | value;
bits += 6;
if (bits >= 8) {
bits -= 8;
if (index < outLength) {
out[index++] = (byte) ((buffer >>> bits) & 0xFF);
}
}
}
if (index == outLength) {
return out;
}
byte[] trimmed = new byte[index];
System.arraycopy(out, 0, trimmed, 0, index);
return trimmed;
}
}
@@ -0,0 +1,142 @@
package com.lanremote.watch;
import android.content.Context;
import android.content.SharedPreferences;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 设备与连接信息持久化。
*
* 配对成功后保存令牌,下次打开直接从设备列表一键连接,无需再输配对码。
*/
public class DeviceStore {
private static final String PREFS = "lanremote";
private static final String KEY_DEVICES = "devices";
private static final String KEY_DEVICE_ID = "device_id";
private static final String KEY_DEVICE_NAME = "device_name";
private static final String KEY_LAST_HOST = "last_host";
private static final String KEY_LAST_PORT = "last_port";
private final SharedPreferences prefs;
public DeviceStore(Context context) {
this.prefs = context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
// ---------------- 本机标识 ----------------
public String deviceId() {
String id = prefs.getString(KEY_DEVICE_ID, "");
if (id == null || id.isEmpty()) {
id = "watch-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
prefs.edit().putString(KEY_DEVICE_ID, id).apply();
}
return id;
}
public String deviceName() {
String name = prefs.getString(KEY_DEVICE_NAME, "");
if (name == null || name.isEmpty()) {
name = android.os.Build.MODEL;
if (name == null || name.trim().isEmpty()) {
name = "手表";
}
prefs.edit().putString(KEY_DEVICE_NAME, name).apply();
}
return name;
}
public void setDeviceName(String name) {
prefs.edit().putString(KEY_DEVICE_NAME, name == null ? "手表" : name.trim()).apply();
}
// ---------------- 上次连接 ----------------
public String lastHost() {
return prefs.getString(KEY_LAST_HOST, "");
}
public int lastPort() {
return prefs.getInt(KEY_LAST_PORT, RemoteClient.DEFAULT_PORT);
}
public void setLastEndpoint(String host, int port) {
prefs.edit().putString(KEY_LAST_HOST, host).putInt(KEY_LAST_PORT, port).apply();
}
// ---------------- 已配对设备 ----------------
public List<Map<String, Object>> devices() {
return readDevices();
}
private List<Map<String, Object>> readDevices() {
String raw = prefs.getString(KEY_DEVICES, "");
if (raw == null || raw.isEmpty()) {
return new ArrayList<Map<String, Object>>();
}
try {
Object parsed = Json.parse(raw);
if (parsed instanceof List) {
List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
for (Object item : (List<?>) parsed) {
Map<String, Object> map = Json.asMap(item);
if (map != null) {
result.add(map);
}
}
return result;
}
} catch (RuntimeException ignored) {
// 存储损坏时当作空列表,不崩溃
}
return new ArrayList<Map<String, Object>>();
}
private void writeDevices(List<Map<String, Object>> devices) {
prefs.edit().putString(KEY_DEVICES, Json.write(devices)).apply();
}
/** 保存或更新一台设备(按 key = 地址:端口:设备ID 去重)。 */
public void saveDevice(String key, String label, String host, int port, String token) {
List<Map<String, Object>> devices = readDevices();
List<Map<String, Object>> updated = new ArrayList<Map<String, Object>>();
for (Map<String, Object> device : devices) {
if (!key.equals(Json.str(device, "key", ""))) {
updated.add(device);
}
}
updated.add(0, Json.obj(
"key", key,
"label", label,
"host", host,
"port", port,
"token", token,
"savedAt", System.currentTimeMillis()));
while (updated.size() > 12) {
updated.remove(updated.size() - 1);
}
writeDevices(updated);
}
public void removeDevice(String key) {
List<Map<String, Object>> devices = readDevices();
List<Map<String, Object>> updated = new ArrayList<Map<String, Object>>();
for (Map<String, Object> device : devices) {
if (!key.equals(Json.str(device, "key", ""))) {
updated.add(device);
}
}
writeDevices(updated);
}
public static String deviceKey(String host, int port, String deviceId) {
return host + ":" + port + ":" + deviceId;
}
}
@@ -0,0 +1,390 @@
package com.lanremote.watch;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.ConnectivityManager;
import android.net.DhcpInfo;
import android.net.NetworkInfo;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.Build;
import android.text.format.DateFormat;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
/**
* 遥控诊断 —— 主程序内置版(原来是一个单独的「遥控诊断」App,现在合并进来)。
*
* 目的:把「为什么连不上」需要的信息一次性收集出来,一键复制。
*
* 只收集排障必需的项:
* 设备型号 / 安卓版本 / 屏幕参数 / 应用版本 / 权限状态 /
* 网络参数 / 真实的 UDP 自动发现结果 / 真实的 TCP 连通结果
*
* 刻意**不收集**:IMEI、手机号、联系人、短信、相册、位置坐标、账号。
* 报告内容全部显示在屏幕上,复制由使用者触发。
*/
public final class Diagnostics {
/** 检测进度回调(会在后台线程里被调用)。 */
public interface Progress {
void step(String text);
}
private Diagnostics() {
}
public static String build(Context context, Progress progress, boolean deepScan) {
StringBuilder sb = new StringBuilder();
sb.append("===== 遥控诊断报告 =====\n");
sb.append("报告版本: 3(主程序内置)\n");
sb.append("生成时间: ").append(
DateFormat.format("yyyy-MM-dd kk:mm:ss", System.currentTimeMillis()))
.append("\n\n");
appendDevice(context, sb);
appendApp(context, sb);
appendPermissions(context, sb);
Discovery.LocalNet net = appendNetwork(context, sb);
List<DiscoveryCore.Found> found =
appendDiscovery(context, sb, net, progress, deepScan);
appendTcpTests(sb, found);
sb.append("\n===== 报告结束 =====\n");
sb.append("(把以上全部内容发给开发者即可)\n");
return sb.toString();
}
// ==================================================================
// 设备
// ==================================================================
private static void appendDevice(Context ctx, StringBuilder sb) {
sb.append("--- 设备 ---\n");
try {
sb.append("厂商: ").append(s(Build.MANUFACTURER)).append("\n");
sb.append("品牌: ").append(s(Build.BRAND)).append("\n");
sb.append("型号: ").append(s(Build.MODEL)).append("\n");
sb.append("设备名: ").append(s(Build.DEVICE)).append("\n");
sb.append("Android 版本: ").append(s(Build.VERSION.RELEASE))
.append(" (API ").append(Build.VERSION.SDK_INT).append(")\n");
sb.append("CPU ABI: ").append(join(Build.SUPPORTED_ABIS)).append("\n");
} catch (Throwable t) {
sb.append("(读取失败)\n");
}
try {
PackageManager pm = ctx.getPackageManager();
sb.append("是否手表设备: ")
.append(pm.hasSystemFeature(PackageManager.FEATURE_WATCH) ? "是" : "否")
.append("\n");
sb.append("有触摸屏: ")
.append(pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) ? "是" : "否")
.append("\n");
sb.append("有 WiFi: ")
.append(pm.hasSystemFeature(PackageManager.FEATURE_WIFI) ? "是" : "否")
.append("\n");
} catch (Throwable t) {
sb.append("(特性读取失败)\n");
}
try {
android.util.DisplayMetrics m = ctx.getResources().getDisplayMetrics();
sb.append("屏幕像素: ").append(m.widthPixels).append(" x ").append(m.heightPixels)
.append("\n");
sb.append("density: ").append(m.density)
.append(" densityDpi: ").append(m.densityDpi).append("\n");
sb.append("屏幕 dp: ").append((int) (m.widthPixels / m.density))
.append(" x ").append((int) (m.heightPixels / m.density)).append("\n");
sb.append("字体缩放: ").append(m.scaledDensity / m.density).append("\n");
} catch (Throwable t) {
sb.append("(屏幕读取失败)\n");
}
sb.append("语言/地区: ").append(Locale.getDefault().toString()).append("\n\n");
}
private static void appendApp(Context ctx, StringBuilder sb) {
sb.append("--- 应用 ---\n");
try {
PackageManager pm = ctx.getPackageManager();
String self = ctx.getPackageName();
PackageInfo info = pm.getPackageInfo(self, 0);
sb.append("包名: ").append(self).append("\n");
sb.append("版本: ").append(info.versionName)
.append(" (").append(info.versionCode).append(")\n");
} catch (Throwable t) {
sb.append("(读取失败)\n");
}
sb.append("\n");
}
private static void appendPermissions(Context ctx, StringBuilder sb) {
sb.append("--- 权限 ---\n");
String[] names = {
Manifest.permission.INTERNET,
Manifest.permission.ACCESS_NETWORK_STATE,
Manifest.permission.ACCESS_WIFI_STATE,
"android.permission.CHANGE_WIFI_MULTICAST_STATE",
Manifest.permission.ACCESS_FINE_LOCATION,
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_AUDIO",
"android.permission.READ_MEDIA_VIDEO",
Manifest.permission.READ_EXTERNAL_STORAGE,
};
for (String name : names) {
sb.append(shortName(name)).append(": ");
if (Build.VERSION.SDK_INT < 23) {
sb.append("安装时授予(旧系统)");
} else {
sb.append(ctx.checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED
? "已授予" : "未授予");
}
sb.append("\n");
}
sb.append("说明: API < 33 时 READ_MEDIA_* 不存在,显示未授予是正常的,\n");
sb.append(" 老系统只看 READ_EXTERNAL_STORAGE。\n\n");
}
// ==================================================================
// 网络
// ==================================================================
private static Discovery.LocalNet appendNetwork(Context ctx, StringBuilder sb) {
sb.append("--- 网络 ---\n");
try {
ConnectivityManager cm = (ConnectivityManager)
ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cm != null) {
NetworkInfo info = cm.getActiveNetworkInfo();
sb.append("连接类型: ")
.append(info == null ? "无网络" : info.getTypeName()
+ (info.isConnected() ? "(已连接)" : "(未连接)"))
.append("\n");
}
} catch (Throwable t) {
sb.append("连接类型: 读取失败\n");
}
try {
WifiManager wm = (WifiManager) ctx.getApplicationContext()
.getSystemService(Context.WIFI_SERVICE);
if (wm != null) {
WifiInfo info = wm.getConnectionInfo();
String ssid = info == null ? null : info.getSSID();
if (ssid == null || ssid.contains("unknown")) {
sb.append("WiFi 名称: 读不到(需要定位权限,或当前不是 WiFi)\n");
} else {
sb.append("WiFi 名称: ").append(ssid.replace("\"", "")).append("\n");
}
if (info != null) {
sb.append("WiFi 信号强度: ").append(info.getRssi()).append(" dBm\n");
sb.append("WiFi 连接速度: ").append(info.getLinkSpeed()).append(" Mbps\n");
}
sb.append("WiFi 开关: ").append(wm.isWifiEnabled() ? "开" : "关").append("\n");
}
} catch (Throwable t) {
sb.append("WiFi 信息: 读取失败\n");
}
sb.append("\n本机所有 IPv4 地址:\n");
try {
for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
for (InterfaceAddress ia : ni.getInterfaceAddresses()) {
InetAddress addr = ia.getAddress();
if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) {
sb.append(" ").append(ni.getName())
.append(": ").append(addr.getHostAddress())
.append("/").append(ia.getNetworkPrefixLength())
.append(ni.isUp() ? " (up)" : " (down)")
.append("\n");
}
}
}
} catch (Throwable t) {
sb.append(" (枚举失败)\n");
}
Discovery.LocalNet net = Discovery.localNet();
if (net != null) {
sb.append("\n本网段: ").append(net.ip).append("/").append(net.prefix)
.append(" 网卡 ").append(net.iface).append("\n");
sb.append("定向广播地址: ").append(net.broadcast()).append("\n");
sb.append("网段可用主机数: ")
.append(DiscoveryCore.subnetHosts(net.ip, net.prefix, 1024).size())
.append("\n");
} else {
sb.append("\n本网段: 读取失败(会影响广播和扫描)\n");
}
try {
WifiManager wm = (WifiManager) ctx.getApplicationContext()
.getSystemService(Context.WIFI_SERVICE);
if (wm != null) {
DhcpInfo dhcp = wm.getDhcpInfo();
if (dhcp != null) {
sb.append("DHCP: 网关 ").append(intToIp(dhcp.gateway))
.append(" 掩码 ").append(intToIp(dhcp.netmask))
.append(" DNS ").append(intToIp(dhcp.dns1))
.append(" / ").append(intToIp(dhcp.dns2)).append("\n");
}
}
} catch (Throwable ignored) {
// 忽略
}
sb.append("\n");
return net;
}
private static List<DiscoveryCore.Found> appendDiscovery(
Context ctx, StringBuilder sb, Discovery.LocalNet net,
Progress progress, boolean deepScan) {
sb.append("--- 自动发现测试(真实执行)---\n");
List<DiscoveryCore.Found> found = new ArrayList<DiscoveryCore.Found>();
String directed = net == null ? null : net.broadcast();
sb.append("广播目标: 255.255.255.255")
.append(directed == null ? "" : ", " + directed).append("\n");
try {
long start = System.currentTimeMillis();
found = Discovery.search(ctx, 4000);
sb.append("广播耗时: ").append(System.currentTimeMillis() - start).append(" ms\n");
sb.append("广播找到: ").append(found.size()).append(" 台\n");
for (DiscoveryCore.Found item : found) {
sb.append(" - ").append(item.toString()).append("\n");
}
} catch (Throwable t) {
sb.append("广播执行失败: ").append(t).append("\n");
}
if (found.isEmpty() && deepScan && net != null && net.prefix <= 30) {
sb.append("\n[广播无结果] 自动切换子网 TCP 扫描(这个最靠谱)\n");
if (progress != null) {
progress.step("广播没结果,正在扫描整个网段的 8765 端口…");
}
try {
long start = System.currentTimeMillis();
List<String> open = Discovery.scanSubnet(ctx, 8765, 600);
sb.append("扫描 ").append(net.ip).append("/").append(net.prefix)
.append(" 的 TCP 8765,耗时 ")
.append(System.currentTimeMillis() - start).append(" ms\n");
sb.append("开放 8765 的地址: ")
.append(open.isEmpty() ? "无" : open.toString()).append("\n");
for (String host : open) {
DiscoveryCore.Found one = Discovery.probeHost(host, 2000);
if (one != null) {
DiscoveryCore.Found merged = new DiscoveryCore.Found(
one.host, one.port, one.name, one.version, "scan+unicast");
found.add(merged);
sb.append(" - ").append(merged.toString()).append("\n");
} else {
found.add(new DiscoveryCore.Found(host, 8765,
"未知(端口开放)", "", "scan"));
sb.append(" - ").append(host)
.append(":8765 端口开放,但 UDP 单播无应答\n");
}
}
} catch (Throwable t) {
sb.append("扫描失败: ").append(t).append("\n");
}
}
if (found.isEmpty()) {
sb.append("\n!! 广播和扫描都没找到电脑。可能原因:\n");
sb.append(" 1. 电脑上被控端没在运行(先确认任务管理器里有 LanRemote-Agent.exe)\n");
sb.append(" 2. 电脑防火墙没放行(被控端窗口上点「一键修复」)\n");
sb.append(" 3. 路由器开了 AP 隔离\n");
sb.append(" 4. 电脑和手表不在同一个网段\n");
}
sb.append("\n");
return found;
}
private static void appendTcpTests(StringBuilder sb, List<DiscoveryCore.Found> found) {
sb.append("--- TCP 连通测试 ---\n");
if (found.isEmpty()) {
sb.append("(没有可测的目标,跳过)\n\n");
return;
}
for (DiscoveryCore.Found item : found) {
DiscoveryCore.PingResult result =
DiscoveryCore.pingDetailed(item.host, item.port, 4000);
sb.append(" ").append(item.host).append(":").append(item.port)
.append(" -> ").append(result.describe()).append("\n");
}
sb.append("\n");
}
/** 手动测一个 IP:广播被拦时用来判断到底是广播问题还是根本不通。 */
public static String manualTest(String ip) {
StringBuilder sb = new StringBuilder();
sb.append("--- 手动测试 ").append(ip).append(" ---\n");
DiscoveryCore.Found direct = Discovery.probeHost(ip, 3000);
sb.append("UDP 单播探测(8766): ")
.append(direct == null ? "无应答" : "有应答 -> " + direct.name
+ " 端口 " + direct.port).append("\n");
int port = direct != null && direct.port > 0 ? direct.port : 8765;
DiscoveryCore.PingResult tcp = DiscoveryCore.pingDetailed(ip, port, 4000);
sb.append("TCP 连接 ").append(ip).append(":").append(port).append(": ")
.append(tcp.describe()).append("\n");
sb.append("结论: ");
if (tcp.ok) {
sb.append("一切正常,可以直接连接。\n");
} else if ("refused".equals(tcp.reason)) {
sb.append("包到达了电脑,但端口没人监听 —— **被控端程序没在运行**。\n");
} else if ("timeout".equals(tcp.reason)) {
sb.append("包没有到达电脑。依次排查:\n");
sb.append(" 1) 电脑是不是休眠/关机了\n");
sb.append(" 2) 电脑连的是不是同一个 WiFi\n");
sb.append(" 3) 路由器有没有开「AP 隔离」\n");
sb.append(" 4) 电脑防火墙是否放行\n");
} else {
sb.append("网络不可达,确认两边在同一网段。\n");
}
return sb.toString();
}
// ==================================================================
// 小工具
// ==================================================================
private static String s(String value) {
return value == null ? "-" : value;
}
private static String join(String[] items) {
if (items == null || items.length == 0) {
return "-";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < items.length; i++) {
if (i > 0) {
sb.append(", ");
}
sb.append(items[i]);
}
return sb.toString();
}
private static String shortName(String permission) {
int dot = permission.lastIndexOf('.');
return dot >= 0 ? permission.substring(dot + 1) : permission;
}
private static String intToIp(int value) {
if (value == 0) {
return "-";
}
return (value & 0xFF) + "." + ((value >> 8) & 0xFF) + "."
+ ((value >> 16) & 0xFF) + "." + ((value >> 24) & 0xFF);
}
}
@@ -0,0 +1,168 @@
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) {
// 释放失败不影响结果
}
}
}
@@ -0,0 +1,449 @@
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<String> subnetHosts(String ip, int prefix, int maxHosts) {
List<String> hosts = new ArrayList<String>();
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;
}
// ==================================================================
// 查找
// ==================================================================
public static List<Found> search(int timeoutMs) {
return search(timeoutMs, null);
}
/**
* 广播搜索。会阻塞 timeoutMs 毫秒,必须在后台线程调用。
*
* @param extraBroadcast 定向广播地址,如 192.168.10.255(可为 null)
*/
public static List<Found> search(int timeoutMs, String extraBroadcast) {
List<Found> results = new ArrayList<Found>();
Set<String> seen = new LinkedHashSet<String>();
DatagramSocket socket = null;
try {
socket = new DatagramSocket();
socket.setBroadcast(true);
socket.setSoTimeout(400);
byte[] payload = PROBE.getBytes("UTF-8");
List<String> targets = new ArrayList<String>();
targets.add("255.255.255.255"); // 受限广播
if (extraBroadcast != null && extraBroadcast.length() > 0) {
targets.add(extraBroadcast); // 本网段定向广播(关键)
}
for (String target : targets) {
try {
socket.send(new DatagramPacket(payload, payload.length,
InetAddress.getByName(target), PORT));
} catch (Exception ignored) {
// 该广播地址不被允许时跳过
}
}
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;
}
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);
}
socket.setSoTimeout(250);
}
} catch (Exception ignored) {
// 网络不可用时返回已收集到的结果
} finally {
if (socket != null) {
socket.close();
}
}
return results;
}
/**
* 子网 TCP 端口扫描。
*
* 用途:广播被路由器/防火墙拦掉时,这是唯一还能找到被控端的办法。
* 直接对本网段 254 个地址并发试连目标端口。
*/
public static List<String> scanSubnet(String ip, int prefix, int port,
int connectTimeoutMs, int maxHosts) {
List<String> hosts = subnetHosts(ip, prefix, maxHosts);
List<String> open = Collections.synchronizedList(new ArrayList<String>());
if (hosts.isEmpty()) {
return open;
}
int threads = Math.min(48, Math.max(8, hosts.size()));
ExecutorService pool = Executors.newFixedThreadPool(threads);
List<Future<?>> tasks = new ArrayList<Future<?>>();
try {
for (final String host : hosts) {
tasks.add(pool.submit(new Callable<Void>() {
@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<String>() {
@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<String> targets = new ArrayList<String>();
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<String, Object> 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<String, Object> 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;
}
}
}
+474
View File
@@ -0,0 +1,474 @@
package com.lanremote.watch;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 极简 JSON 编解码器。
*
* 刻意不依赖 org.json / Gson:这样协议层是**纯 Java**,
* 既能在安卓上跑,也能在桌面 JVM 上做跨语言联调测试。
*/
public final class Json {
private Json() {
}
// ------------------------------------------------------------------
// 构造辅助
// ------------------------------------------------------------------
public static Map<String, Object> obj() {
return new LinkedHashMap<String, Object>();
}
public static Map<String, Object> obj(Object... kv) {
Map<String, Object> map = new LinkedHashMap<String, Object>();
for (int i = 0; i + 1 < kv.length; i += 2) {
map.put(String.valueOf(kv[i]), kv[i + 1]);
}
return map;
}
public static String str(Map<String, Object> map, String key, String fallback) {
Object value = map == null ? null : map.get(key);
return value == null ? fallback : String.valueOf(value);
}
public static int integer(Map<String, Object> map, String key, int fallback) {
Object value = map == null ? null : map.get(key);
if (value instanceof Number) {
return ((Number) value).intValue();
}
if (value == null) {
return fallback;
}
try {
return (int) Double.parseDouble(String.valueOf(value));
} catch (NumberFormatException e) {
return fallback;
}
}
public static boolean bool(Map<String, Object> map, String key, boolean fallback) {
Object value = map == null ? null : map.get(key);
if (value instanceof Boolean) {
return (Boolean) value;
}
if (value == null) {
return fallback;
}
return Boolean.parseBoolean(String.valueOf(value));
}
@SuppressWarnings("unchecked")
/**
* 取一个字符串数组。元素不是字符串的会被跳过。
*
* 中转会把被控端上报的局域网 IP 列表放在这里(lanIps),
* 手表用它判断能不能走局域网直连。
*/
public static List<String> stringList(Map<String, Object> map, String key) {
List<String> out = new java.util.ArrayList<String>();
List<Object> raw = list(map, key);
if (raw == null) {
return out;
}
for (Object item : raw) {
if (item instanceof String) {
String text = ((String) item).trim();
if (!text.isEmpty()) {
out.add(text);
}
}
}
return out;
}
public static List<Object> list(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value instanceof List) {
return (List<Object>) value;
}
return new ArrayList<Object>();
}
@SuppressWarnings("unchecked")
public static Map<String, Object> asMap(Object value) {
return value instanceof Map ? (Map<String, Object>) value : null;
}
// ------------------------------------------------------------------
// 编码
// ------------------------------------------------------------------
/**
* 把字符串转义成能安全塞进 JSON 字符串字面量的形式。
* 只处理引号、反斜杠和控制字符 —— 中文不用转,JSON 本身是 UTF-8。
*/
public static String escape(String text) {
if (text == null) {
return "";
}
StringBuilder out = new StringBuilder(text.length() + 16);
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
switch (ch) {
case '"':
out.append("\\\"");
break;
case '\\':
out.append("\\\\");
break;
case '\n':
out.append("\\n");
break;
case '\r':
out.append("\\r");
break;
case '\t':
out.append("\\t");
break;
default:
if (ch < 0x20) {
out.append(String.format("\\u%04x", (int) ch));
} else {
out.append(ch);
}
break;
}
}
return out.toString();
}
public static String write(Object value) {
StringBuilder sb = new StringBuilder(256);
writeValue(sb, value);
return sb.toString();
}
private static void writeValue(StringBuilder sb, Object value) {
if (value == null) {
sb.append("null");
} else if (value instanceof String) {
writeString(sb, (String) value);
} else if (value instanceof Boolean) {
sb.append(((Boolean) value) ? "true" : "false");
} else if (value instanceof Double || value instanceof Float) {
double d = ((Number) value).doubleValue();
if (d == Math.rint(d) && !Double.isInfinite(d)) {
sb.append((long) d);
} else {
sb.append(d);
}
} else if (value instanceof Number) {
sb.append(((Number) value).longValue());
} else if (value instanceof Map) {
writeObject(sb, (Map<?, ?>) value);
} else if (value instanceof Iterable) {
writeArray(sb, (Iterable<?>) value);
} else if (value instanceof byte[]) {
writeString(sb, new String((byte[]) value, java.nio.charset.Charset.forName("UTF-8")));
} else {
writeString(sb, String.valueOf(value));
}
}
private static void writeObject(StringBuilder sb, Map<?, ?> map) {
sb.append('{');
boolean first = true;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (!first) {
sb.append(',');
}
first = false;
writeString(sb, String.valueOf(entry.getKey()));
sb.append(':');
writeValue(sb, entry.getValue());
}
sb.append('}');
}
private static void writeArray(StringBuilder sb, Iterable<?> items) {
sb.append('[');
boolean first = true;
for (Object item : items) {
if (!first) {
sb.append(',');
}
first = false;
writeValue(sb, item);
}
sb.append(']');
}
private static void writeString(StringBuilder sb, String text) {
sb.append('"');
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
case '\b': sb.append("\\b"); break;
case '\f': sb.append("\\f"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
}
// ------------------------------------------------------------------
// 解码
// ------------------------------------------------------------------
public static Object parse(String text) {
Parser parser = new Parser(text);
parser.skipWhitespace();
Object value = parser.readValue();
parser.skipWhitespace();
if (!parser.atEnd()) {
throw new IllegalArgumentException("JSON 尾部存在多余内容");
}
return value;
}
public static Map<String, Object> parseObject(String text) {
Object value = parse(text);
Map<String, Object> map = asMap(value);
if (map == null) {
throw new IllegalArgumentException("期望 JSON 对象");
}
return map;
}
private static final class Parser {
/** 最大嵌套深度。不加限制的话,一行 "[[[[…" 就能把栈撑爆,
* 而 UDP 发现的入口是局域网里任何主机都能打的,等于远程 DoS。 */
private static final int MAX_DEPTH = 64;
private final String src;
private int pos;
private int depth;
Parser(String src) {
this.src = src;
}
boolean atEnd() {
return pos >= src.length();
}
void skipWhitespace() {
while (pos < src.length()) {
char c = src.charAt(pos);
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
pos++;
} else {
break;
}
}
}
Object readValue() {
if (atEnd()) {
throw new IllegalArgumentException("JSON 意外结束");
}
if (depth > MAX_DEPTH) {
throw new IllegalArgumentException("JSON 嵌套太深");
}
char c = src.charAt(pos);
switch (c) {
case '{': return readObject();
case '[': return readArray();
case '"': return readString();
case 't':
expect("true");
return Boolean.TRUE;
case 'f':
expect("false");
return Boolean.FALSE;
case 'n':
expect("null");
return null;
default:
return readNumber();
}
}
private void expect(String literal) {
if (!src.startsWith(literal, pos)) {
throw new IllegalArgumentException("位置 " + pos + " 处 JSON 非法");
}
pos += literal.length();
}
private Map<String, Object> readObject() {
if (depth >= MAX_DEPTH) {
throw new IllegalArgumentException("JSON 嵌套太深");
}
depth++;
try {
return readObjectInner();
} finally {
// 必须配对减去!只加不减会让计数器单调递增,
// 于是"回复里有 65 个对象"就被误判成嵌套太深,
// 而调用方会静默丢弃这条回复 -> 表现为"连不上电脑"。
depth--;
}
}
private Map<String, Object> readObjectInner() {
Map<String, Object> map = new LinkedHashMap<String, Object>();
pos++; // '{'
skipWhitespace();
if (!atEnd() && src.charAt(pos) == '}') {
pos++;
return map;
}
while (true) {
skipWhitespace();
String key = readString();
skipWhitespace();
if (atEnd() || src.charAt(pos) != ':') {
throw new IllegalArgumentException("对象缺少 ':',位置 " + pos);
}
pos++;
skipWhitespace();
map.put(key, readValue());
skipWhitespace();
if (atEnd()) {
throw new IllegalArgumentException("对象未闭合");
}
char c = src.charAt(pos);
if (c == ',') {
pos++;
continue;
}
if (c == '}') {
pos++;
return map;
}
throw new IllegalArgumentException("对象分隔符非法,位置 " + pos);
}
}
private List<Object> readArray() {
if (depth >= MAX_DEPTH) {
throw new IllegalArgumentException("JSON 嵌套太深");
}
depth++;
try {
return readArrayInner();
} finally {
depth--;
}
}
private List<Object> readArrayInner() {
List<Object> items = new ArrayList<Object>();
pos++; // '['
skipWhitespace();
if (!atEnd() && src.charAt(pos) == ']') {
pos++;
return items;
}
while (true) {
skipWhitespace();
items.add(readValue());
skipWhitespace();
if (atEnd()) {
throw new IllegalArgumentException("数组未闭合");
}
char c = src.charAt(pos);
if (c == ',') {
pos++;
continue;
}
if (c == ']') {
pos++;
return items;
}
throw new IllegalArgumentException("数组分隔符非法,位置 " + pos);
}
}
private String readString() {
if (atEnd() || src.charAt(pos) != '"') {
throw new IllegalArgumentException("期望字符串,位置 " + pos);
}
pos++;
StringBuilder sb = new StringBuilder(64);
while (true) {
if (atEnd()) {
throw new IllegalArgumentException("字符串未闭合");
}
char c = src.charAt(pos++);
if (c == '"') {
return sb.toString();
}
if (c != '\\') {
sb.append(c);
continue;
}
if (atEnd()) {
throw new IllegalArgumentException("转义字符未完成");
}
char esc = src.charAt(pos++);
switch (esc) {
case '"': sb.append('"'); break;
case '\\': sb.append('\\'); break;
case '/': sb.append('/'); break;
case 'n': sb.append('\n'); break;
case 'r': sb.append('\r'); break;
case 't': sb.append('\t'); break;
case 'b': sb.append('\b'); break;
case 'f': sb.append('\f'); break;
case 'u':
if (pos + 4 > src.length()) {
throw new IllegalArgumentException("\\u 转义不完整");
}
sb.append((char) Integer.parseInt(src.substring(pos, pos + 4), 16));
pos += 4;
break;
default:
throw new IllegalArgumentException("未知转义 \\" + esc);
}
}
}
private Object readNumber() {
int start = pos;
while (pos < src.length()) {
char c = src.charAt(pos);
if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.'
|| c == 'e' || c == 'E') {
pos++;
} else {
break;
}
}
String token = src.substring(start, pos);
if (token.isEmpty()) {
throw new IllegalArgumentException("非法数字,位置 " + start);
}
if (token.indexOf('.') < 0 && token.indexOf('e') < 0 && token.indexOf('E') < 0) {
try {
return Long.valueOf(token);
} catch (NumberFormatException ignored) {
// 落到 double
}
}
return Double.valueOf(token);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,286 @@
package com.lanremote.watch;
import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.Context;
import android.content.DialogInterface;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.provider.MediaStore;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
/**
* 内置媒体选择器。
*
* 为什么不直接用系统的 ACTION_OPEN_DOCUMENT:
* 那会每次弹出系统文件浏览器。用户要求「操作时不要弹窗」,
* 所以在启动时一次性申请好媒体读取权限,之后用 MediaStore 自己列文件。
* 权限没给或系统版本不支持时,MainActivity 会回退到系统选择器。
*/
public final class MediaPicker {
private MediaPicker() {
}
public static class Item {
public final String name;
public final long size;
public final Uri uri;
Item(String name, long size, Uri uri) {
this.name = name;
this.size = size;
this.uri = uri;
}
@Override
public String toString() {
return name;
}
}
public interface OnPick {
void onPick(Item item);
}
// ------------------------------------------------------------------
// 查询
// ------------------------------------------------------------------
public static List<Item> listImages(Context context, int limit) {
return query(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, limit);
}
public static List<Item> listAudio(Context context, int limit) {
return query(context, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, limit);
}
public static List<Item> listVideos(Context context, int limit) {
return query(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, limit);
}
/** 所有被媒体库索引到的文件(用于「发送文件」)。 */
public static List<Item> listFiles(Context context, int limit) {
return query(context, MediaStore.Files.getContentUri("external"), limit);
}
private static List<Item> query(Context context, Uri collection, int limit) {
List<Item> items = new ArrayList<Item>();
ContentResolver resolver = context.getContentResolver();
String[] projection = {
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.SIZE,
};
Cursor cursor = null;
try {
cursor = resolver.query(collection, projection, null, null,
MediaStore.MediaColumns.DATE_ADDED + " DESC");
if (cursor == null) {
return items;
}
int idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID);
int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME);
int sizeIndex = cursor.getColumnIndex(MediaStore.MediaColumns.SIZE);
while (cursor.moveToNext() && items.size() < limit) {
long id = idIndex >= 0 ? cursor.getLong(idIndex) : -1;
if (id < 0) {
continue;
}
String name = nameIndex >= 0 ? cursor.getString(nameIndex) : null;
if (name == null || name.isEmpty()) {
name = "未命名_" + id;
}
long size = sizeIndex >= 0 ? cursor.getLong(sizeIndex) : 0;
items.add(new Item(name, size,
Uri.withAppendedPath(collection, String.valueOf(id))));
}
} catch (Exception ignored) {
// 权限不足或 MediaStore 不可用时返回空列表,由调用方回退
} finally {
if (cursor != null) {
cursor.close();
}
}
return items;
}
// ------------------------------------------------------------------
// 缩略图
// ------------------------------------------------------------------
/** 按目标边长解码缩略图。所有 Android 版本都可用。 */
public static Bitmap thumbnail(Context context, Uri uri, int targetPx) {
try {
BitmapFactory.Options bounds = new BitmapFactory.Options();
bounds.inJustDecodeBounds = true;
InputStream probe = context.getContentResolver().openInputStream(uri);
BitmapFactory.decodeStream(probe, null, bounds);
if (probe != null) {
probe.close();
}
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
return null;
}
int sample = 1;
while (bounds.outWidth / sample > targetPx * 2
|| bounds.outHeight / sample > targetPx * 2) {
sample *= 2;
}
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = sample;
InputStream stream = context.getContentResolver().openInputStream(uri);
Bitmap bitmap = BitmapFactory.decodeStream(stream, null, options);
if (stream != null) {
stream.close();
}
return bitmap;
} catch (Exception e) {
return null;
}
}
// ------------------------------------------------------------------
// 选择对话框
// ------------------------------------------------------------------
public static void show(final Context context, String title, final List<Item> items,
final OnPick callback, final Runnable onCancel) {
if (items.isEmpty()) {
new AlertDialog.Builder(context)
.setTitle(title)
.setMessage("没有找到可用的文件。\n\n"
+ "如果权限被拒绝了,可以到系统设置里手动开启,"
+ "或者直接用手动选择文件的入口。")
.setPositiveButton("知道了", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
if (onCancel != null) {
onCancel.run();
}
}
})
.show();
return;
}
final ListView list = new ListView(context);
list.setBackgroundColor(0xFFF5F7FA);
list.setAdapter(new PickerAdapter(context, items, callback));
new AlertDialog.Builder(context)
.setTitle(title)
.setView(list)
.setNegativeButton("取消", null)
.show();
}
/** 缩略图 + 名字 + 大小 的列表适配器。 */
private static class PickerAdapter extends ArrayAdapter<Item> {
private final Context context;
private final List<Item> items;
private final OnPick callback;
private final int rowHeight;
private final int thumbSize;
PickerAdapter(Context context, List<Item> items, OnPick callback) {
super(context, 0, items);
this.context = context;
this.items = items;
this.callback = callback;
this.rowHeight = dp(56);
this.thumbSize = dp(48);
}
private int dp(float value) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value,
context.getResources().getDisplayMetrics());
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
LinearLayout row;
ImageView thumb;
TextView title;
TextView subtitle;
if (convertView instanceof LinearLayout) {
row = (LinearLayout) convertView;
thumb = (ImageView) row.getChildAt(0);
LinearLayout textBox = (LinearLayout) row.getChildAt(1);
title = (TextView) textBox.getChildAt(0);
subtitle = (TextView) textBox.getChildAt(1);
} else {
row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(dp(10), dp(4), dp(10), dp(4));
row.setMinimumHeight(rowHeight);
thumb = new ImageView(context);
LinearLayout.LayoutParams thumbParams =
new LinearLayout.LayoutParams(thumbSize, thumbSize);
thumbParams.setMargins(0, 0, dp(10), 0);
thumb.setLayoutParams(thumbParams);
thumb.setScaleType(ImageView.ScaleType.CENTER_CROP);
row.addView(thumb);
LinearLayout textBox = new LinearLayout(context);
textBox.setOrientation(LinearLayout.VERTICAL);
row.addView(textBox, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
title = new TextView(context);
title.setTextColor(0xFF1F2328);
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
title.setMaxLines(2);
textBox.addView(title);
subtitle = new TextView(context);
subtitle.setTextColor(0xFF656D76);
subtitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11);
textBox.addView(subtitle);
}
final Item item = items.get(position);
title.setText(item.name);
subtitle.setText(humanSize(item.size));
thumb.setImageBitmap(thumbnail(context, item.uri, thumbSize));
row.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (callback != null) {
callback.onPick(item);
}
}
});
return row;
}
}
public static String humanSize(long bytes) {
if (bytes < 1024) {
return bytes + " B";
}
if (bytes < 1024 * 1024) {
return String.format(java.util.Locale.getDefault(), "%.1f KB", bytes / 1024.0);
}
return String.format(java.util.Locale.getDefault(), "%.1f MB", bytes / 1048576.0);
}
}
@@ -0,0 +1,526 @@
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;
/** 长轮询:中转那边挂 25 秒,客户端超时给宽一点 */
private static final int POLL_TIMEOUT_MS = 40000;
/** 发送/接收的单次请求超时 */
private static final int SEND_TIMEOUT_MS = 30000;
/** 网络出错后的退避 */
private static final int RETRY_MIN_MS = 1500;
private static final int RETRY_MAX_MS = 15000;
/** 单次读到的字节上限 */
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<byte[]> inbox =
new LinkedBlockingQueue<byte[]>();
private byte[] current;
private int currentOffset;
/** 待发送的字节 */
private final ByteArrayOutputStream pending = new ByteArrayOutputStream();
private final Object pendingLock = new Object();
private Thread pollThread;
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<String> lanIps = new java.util.ArrayList<String>();
/** 被控端监听端口。0 表示它没报(那就没法直连)。 */
private int lanPort;
public java.util.List<String> 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<String, Object> 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) {
online = false;
lastError = "轮询返回 " + result.code;
sleep(backoff);
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
continue;
}
online = true;
lastError = "";
backoff = RETRY_MIN_MS;
if (result.body.length > 0) {
inbox.offer(result.body);
}
} catch (Exception exc) {
if (closed) {
return;
}
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<String, Object> 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<String, Object> 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();
}
}
}
@@ -0,0 +1,342 @@
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;
}
}
}
@@ -0,0 +1,271 @@
package com.lanremote.watch;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.KeyStore;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
/**
* TLS 兼容层:让老手表也能连上中转。
*
* <p>为什么需要:
* 手表端报 {@code SSLHandshakeException: Chain validation failed}。
* 中转的证书链是
* {@code relay.xxmxxs.top <- GTS WE1 <- GTS Root R4 <- GlobalSign Root CA},
* 而很多廉价手表的系统根证书库缺 GTS Root R4(AOSP 2021 年才加),
* 于是找不到信任锚,握手直接失败。
*
* <p>做法:
* 把这两个根证书内嵌进 APK,校验时<b>系统库或内置库任一通过即可</b>。
* 这两个都是公开的合法根证书,<b>不是跳过校验</b>:
* 证书仍必须由可信 CA 签发、且域名匹配,只是把可信 CA 的集合
* 从"手表自带的那一份"扩大到"手表自带的 + 这两个"。
*
* <p>同时把难懂的异常翻译成用户能看懂的话。
*/
public final class TlsCompat {
private TlsCompat() {
}
// ---------------- 内置的根证书 ----------------
/** Google Trust Services Root R4(自签名根,EC P-384)。 */
private static final String GTS_ROOT_R4 =
"-----BEGIN CERTIFICATE-----\n" +
"MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD\n" +
"VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n" +
"A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n" +
"WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n" +
"IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n" +
"AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi\n" +
"QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR\n" +
"HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n" +
"BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D\n" +
"9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8\n" +
"p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD\n" +
"-----END CERTIFICATE-----\n";
/** GlobalSign Root CA(自签名根,RSA 2048,1998 年起就在各系统里)。 */
private static final String GLOBALSIGN_ROOT_CA =
"-----BEGIN CERTIFICATE-----\n" +
"MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG\n" +
"A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv\n" +
"b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw\n" +
"MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i\n" +
"YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT\n" +
"aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ\n" +
"jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp\n" +
"xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp\n" +
"1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG\n" +
"snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ\n" +
"U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8\n" +
"9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E\n" +
"BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B\n" +
"AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz\n" +
"yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE\n" +
"38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP\n" +
"AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad\n" +
"DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME\n" +
"HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==\n" +
"-----END CERTIFICATE-----\n";
private static SSLSocketFactory factory;
private static X509TrustManager bundled;
private static boolean ready;
private static boolean initTried;
// ---------------- 对外 ----------------
/**
* 打开一个连接,HTTPS 时挂上兼容的 SSLSocketFactory。
*
* <p>HTTP(局域网中转)不走这里,原样返回。
*/
public static HttpURLConnection open(String urlText) throws IOException {
HttpURLConnection connection =
(HttpURLConnection) new URL(urlText).openConnection();
connection.setUseCaches(false);
if (connection instanceof HttpsURLConnection) {
SSLSocketFactory ssl = factory();
if (ssl != null) {
((HttpsURLConnection) connection).setSSLSocketFactory(ssl);
}
}
return connection;
}
/** 拿到兼容的 SSLSocketFactory;准备失败就返回 null(退回系统默认)。 */
public static synchronized SSLSocketFactory factory() {
if (!initTried) {
initTried = true;
try {
KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType());
store.load(null, null);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
int index = 0;
for (String pem : new String[]{GTS_ROOT_R4, GLOBALSIGN_ROOT_CA}) {
X509Certificate cert = (X509Certificate) cf.generateCertificate(
new ByteArrayInputStream(pem.getBytes("UTF-8")));
store.setCertificateEntry("lanremote-ca-" + (index++), cert);
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
tmf.init(store);
for (TrustManager tm : tmf.getTrustManagers()) {
if (tm instanceof X509TrustManager) {
bundled = (X509TrustManager) tm;
break;
}
}
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[]{new CompatTrustManager()}, null);
factory = context.getSocketFactory();
ready = true;
} catch (Throwable error) {
// 准备失败就退回系统默认,不影响正常设备
factory = null;
ready = false;
}
}
return ready ? factory : null;
}
/**
* 把 TLS / 网络异常翻译成用户能看懂的一句话。
*
* <p>手表上原本只会显示一长串英文类名,用户完全不知道该怎么办。
*/
public static String explain(Throwable error) {
if (error == null) {
return "未知错误";
}
String text = String.valueOf(error);
String lower = text.toLowerCase();
if (lower.contains("chain validation failed")
|| lower.contains("trust anchor")
|| lower.contains("certpathvalidator")) {
return "证书校验失败(中转的根证书手表不认识)。"
+ "可能是手表系统太旧,或网络被劫持。";
}
if (lower.contains("not yet valid") || lower.contains("expired")
|| lower.contains("certificate expired")) {
return "证书时间不对 —— 把手表的【日期和时间】调成自动获取,"
+ "再试一次。";
}
if (lower.contains("timeout") || lower.contains("timed out")) {
return "连接超时(网络太慢或被挡住)";
}
if (lower.contains("unable to resolve host")
|| lower.contains("unknownhost")) {
return "域名解析不了(检查手表能不能上网)";
}
if (lower.contains("connection refused")) {
return "对方拒绝了连接(电脑上 ykxmz 没开?)";
}
if (lower.contains("network is unreachable")
|| lower.contains("no route")) {
return "手表没联网(检查 WiFi)";
}
if (lower.contains("eof") || lower.contains("reset")) {
return "连接被中断(网络不稳)";
}
if (text.length() > 80) {
return text.substring(0, 80);
}
return text;
}
/**
* 组合信任:系统默认 或 内置根证书,任一通过即认可。
*
* <p>这是修这个问题的关键 ——
* 老手表缺 GTS Root R4,但内置的那份有,于是校验通过;
* 新设备走系统默认照样通过。两边都不削弱安全性。
*/
private static final class CompatTrustManager implements X509TrustManager {
private final X509TrustManager system = findSystem();
private static X509TrustManager findSystem() {
try {
TrustManagerFactory tmf = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore) null); // null = 系统默认
for (TrustManager tm : tmf.getTrustManagers()) {
if (tm instanceof X509TrustManager) {
return (X509TrustManager) tm;
}
}
} catch (Throwable ignored) {
// 拿不到就算了,下面用内置的
}
return null;
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws java.security.cert.CertificateException {
if (system != null) {
system.checkClientTrusted(chain, authType);
return;
}
if (bundled != null) {
bundled.checkClientTrusted(chain, authType);
}
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws java.security.cert.CertificateException {
java.security.cert.CertificateException first = null;
if (system != null) {
try {
system.checkServerTrusted(chain, authType);
return;
} catch (java.security.cert.CertificateException error) {
first = error;
}
}
if (bundled != null) {
try {
bundled.checkServerTrusted(chain, authType);
return;
} catch (java.security.cert.CertificateException error) {
if (first == null) {
first = error;
}
}
}
throw (first != null ? first
: new java.security.cert.CertificateException(
"证书校验失败"));
}
@Override
public X509Certificate[] getAcceptedIssuers() {
X509Certificate[] a = system == null
? new X509Certificate[0] : system.getAcceptedIssuers();
X509Certificate[] b = bundled == null
? new X509Certificate[0] : bundled.getAcceptedIssuers();
X509Certificate[] all = new X509Certificate[a.length + b.length];
System.arraycopy(a, 0, all, 0, a.length);
System.arraycopy(b, 0, all, a.length, b.length);
return all;
}
}
}
@@ -0,0 +1,88 @@
package com.lanremote.watch;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
/**
* RemoteClient 需要的传输层。
*
* 为什么要抽这一层:
* 原来 RemoteClient 直接用 Socket,只能在同一局域网里连。
* 跨网络时手表在 4G 上、电脑在 NAT 后面,直连不可能,
* 只能两边都主动连出去、由中转服务器对接。
* 把"怎么传字节"抽出来之后,RemoteClient 的协议逻辑一行都不用改:
* SocketTransport —— 原来的局域网直连
* RelayTransport —— 走中转服务器(HTTPS 长轮询)
*/
public interface Transport {
/** 来自对端的字节流。read() 应该阻塞等待。 */
InputStream input() throws IOException;
/** 发给对端的字节流。 */
OutputStream output() throws IOException;
/** 对端已经断开 / 自己关掉了。 */
boolean isClosed();
/** 关掉,释放资源。可以重复调用。 */
void close();
/** 显示用的对端描述("192.168.2.64:8765" 或 "中转服务器")。 */
String describe();
// ==================================================================
/** 局域网直连:就是原来的 Socket。 */
final class SocketTransport implements Transport {
private final Socket socket;
private final InputStream in;
private final OutputStream out;
private volatile boolean closed;
public SocketTransport(Socket socket) throws IOException {
this.socket = socket;
this.in = socket.getInputStream();
this.out = socket.getOutputStream();
}
public Socket socket() {
return socket;
}
@Override
public InputStream input() {
return in;
}
@Override
public OutputStream output() {
return out;
}
@Override
public boolean isClosed() {
return closed || socket.isClosed();
}
@Override
public void close() {
closed = true;
try {
socket.close();
} catch (IOException ignored) {
// 关的时候出错不用管
}
}
@Override
public String describe() {
return socket.getInetAddress() == null
? "电脑"
: socket.getInetAddress().getHostAddress() + ":"
+ socket.getPort();
}
}
}