mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 14:50:45 +00:00
ykxmz 1.5.0:修复手表端 TLS 证书校验 + MTU 修复网络稳定性
被控端 - 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%) - 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败) - 每分钟回头找局域网,自动切回快 400 倍的路径 - 画面心跳帧:静止时省流量,客户端仍知道流是活的 - 锁屏时给出可操作的提示 手表端 - 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException - 连接错误翻译成人话(证书/时间/解析/超时分别提示) - 经中转连上后用服务器报的地址刷新已配对记录 - 新增局域网连接页面、服务器延迟检测 服务器端 - 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502) - 网络调优脚本 net-tune.sh(开机自动应用) - 管理页:批量删除、网络体检、下载区 - 隧道改 HTTP/2 + 双连接 文档 - 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.lanremote.watch"
|
||||
android:versionCode="5"
|
||||
android:versionName="1.5.0">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!--
|
||||
启动时一次性申请完,之后选图片/音频不再弹权限框。
|
||||
Android 13(API 33) 起用细分媒体权限;12 及以下用 READ_EXTERNAL_STORAGE。
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
|
||||
<uses-feature android:name="android.hardware.type.watch" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:hardwareAccelerated="true"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,133 @@
|
||||
# 用 aapt2 + javac + d8 + zipalign + apksigner 手工构建 APK(不需要 Gradle)
|
||||
# 用法: powershell -File android\build_apk.ps1
|
||||
#
|
||||
# 注意:刻意不用 $ErrorActionPreference="Stop"。
|
||||
# 原生工具的告警(javac 的 deprecation note 等)会写 stderr,
|
||||
# 在 Stop 模式下会被 PowerShell 当成致命错误中断构建。
|
||||
# 改为每一步显式检查 $LASTEXITCODE。
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
$android = $PSScriptRoot # LanRemote/android(本脚本所在目录)
|
||||
$root = Split-Path -Parent $android # LanRemote
|
||||
$tc = Join-Path $root "..\starklink\toolchain"
|
||||
$jdk = Join-Path $tc "jdk17"
|
||||
$sdk = Join-Path $tc "android-sdk"
|
||||
$bt = Join-Path $sdk "build-tools\34.0.0"
|
||||
$androidJar = Join-Path $sdk "platforms\android-34\android.jar"
|
||||
$keystore = Join-Path $tc "debug.keystore"
|
||||
|
||||
$javac = Join-Path $jdk "bin\javac.exe"
|
||||
$keytool = Join-Path $jdk "bin\keytool.exe"
|
||||
$jar = Join-Path $jdk "bin\jar.exe"
|
||||
|
||||
$aapt2 = Join-Path $bt "aapt2.exe"
|
||||
$zipalign = Join-Path $bt "zipalign.exe"
|
||||
$apksigner= Join-Path $bt "apksigner.bat"
|
||||
$d8 = Join-Path $bt "d8.bat"
|
||||
|
||||
$work = Join-Path $android "build"
|
||||
$outDir = Join-Path $root "dist"
|
||||
$apkName = "ykxmz-watch-1.5.0.apk"
|
||||
|
||||
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
|
||||
|
||||
# 前置检查
|
||||
foreach ($tool in @($javac, $aapt2, $d8, $zipalign, $apksigner)) {
|
||||
if (-not (Test-Path $tool)) { throw "缺少工具: $tool" }
|
||||
}
|
||||
if (-not (Test-Path $androidJar)) { throw "缺少 android.jar: $androidJar" }
|
||||
if (-not (Test-Path $keystore)) { throw "缺少签名用 keystore: $keystore" }
|
||||
|
||||
$env:JAVA_HOME = $jdk
|
||||
$env:PATH = "$(Join-Path $jdk 'bin');$env:PATH"
|
||||
|
||||
if (Test-Path $work) { Remove-Item -Recurse -Force $work }
|
||||
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
|
||||
|
||||
# ---------------- 1. 编译资源 ----------------
|
||||
Step "1/6 aapt2 compile 资源"
|
||||
$compiled = Join-Path $work "res.zip"
|
||||
& $aapt2 compile --dir (Join-Path $android "res") -o $compiled
|
||||
if ($LASTEXITCODE -ne 0) { throw "aapt2 compile 失败" }
|
||||
|
||||
# ---------------- 2. 链接资源 + 生成 R.java ----------------
|
||||
Step "2/6 aapt2 link 生成资源表与 R.java"
|
||||
$genDir = Join-Path $work "gen"
|
||||
New-Item -ItemType Directory -Force -Path $genDir | Out-Null
|
||||
$unsigned = Join-Path $work "app-unsigned.apk"
|
||||
& $aapt2 link `
|
||||
-o $unsigned `
|
||||
-I $androidJar `
|
||||
--manifest (Join-Path $android "AndroidManifest.xml") `
|
||||
-R $compiled `
|
||||
--java $genDir `
|
||||
--min-sdk-version 24 `
|
||||
--target-sdk-version 34 `
|
||||
--version-code 5 `
|
||||
--version-name "1.5.0" `
|
||||
--auto-add-overlay
|
||||
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
|
||||
|
||||
# ---------------- 3. 编译 Java ----------------
|
||||
Step "3/6 javac 编译 Java 源码"
|
||||
$classesDir = Join-Path $work "classes"
|
||||
New-Item -ItemType Directory -Force -Path $classesDir | Out-Null
|
||||
|
||||
$javaSources = @()
|
||||
$javaSources += Get-ChildItem -Recurse -Filter *.java -Path (Join-Path $android "src") | ForEach-Object { $_.FullName }
|
||||
$javaSources += Get-ChildItem -Recurse -Filter R.java -Path $genDir -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }
|
||||
if ($javaSources.Count -eq 0) { throw "没有找到 Java 源文件" }
|
||||
Write-Host "源文件 $($javaSources.Count) 个"
|
||||
|
||||
& $javac -source 8 -target 8 -nowarn -Xlint:none -encoding UTF-8 `
|
||||
-bootclasspath $androidJar `
|
||||
-d $classesDir `
|
||||
$javaSources 2>&1 | Out-String | Write-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "javac 编译失败" }
|
||||
|
||||
# ---------------- 4. d8 转 dex ----------------
|
||||
Step "4/6 d8 转换为 classes.dex"
|
||||
$classesJar = Join-Path $work "classes.jar"
|
||||
& $jar cf $classesJar -C $classesDir .
|
||||
if ($LASTEXITCODE -ne 0) { throw "打包 classes.jar 失败" }
|
||||
|
||||
$dexDir = Join-Path $work "dex"
|
||||
New-Item -ItemType Directory -Force -Path $dexDir | Out-Null
|
||||
& $d8 --lib $androidJar --min-api 24 --output $dexDir $classesJar 2>&1 | Out-String | Write-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "d8 失败" }
|
||||
$dex = Join-Path $dexDir "classes.dex"
|
||||
if (-not (Test-Path $dex)) { throw "未生成 classes.dex" }
|
||||
Write-Host ("classes.dex 大小 {0:N1} KB" -f ((Get-Item $dex).Length / 1KB))
|
||||
|
||||
# ---------------- 5. 组装 APK ----------------
|
||||
Step "5/6 组装 APK"
|
||||
& python (Join-Path $root "_build\pack_apk.py") $unsigned $dex
|
||||
if ($LASTEXITCODE -ne 0) { throw "写入 classes.dex 失败" }
|
||||
|
||||
$aligned = Join-Path $work "app-aligned.apk"
|
||||
& $zipalign -f 4 $unsigned $aligned
|
||||
if ($LASTEXITCODE -ne 0) { throw "zipalign 失败" }
|
||||
|
||||
# ---------------- 6. 签名 ----------------
|
||||
Step "6/6 apksigner 签名"
|
||||
$final = Join-Path $outDir $apkName
|
||||
if (Test-Path $final) { Remove-Item $final -Force }
|
||||
& $apksigner sign `
|
||||
--ks $keystore `
|
||||
--ks-pass pass:android `
|
||||
--key-pass pass:android `
|
||||
--ks-key-alias androiddebugkey `
|
||||
--v1-signing-enabled true `
|
||||
--v2-signing-enabled true `
|
||||
--out $final `
|
||||
$aligned
|
||||
if ($LASTEXITCODE -ne 0) { throw "签名失败" }
|
||||
|
||||
Step "验证签名"
|
||||
& $apksigner verify --print-certs $final
|
||||
if ($LASTEXITCODE -ne 0) { throw "签名校验失败" }
|
||||
|
||||
Step "构建完成"
|
||||
Write-Host ("APK: {0}" -f $final) -ForegroundColor Green
|
||||
Write-Host ("大小: {0:N2} MB" -f ((Get-Item $final).Length / 1MB)) -ForegroundColor Green
|
||||
@@ -0,0 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG
|
||||
A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv
|
||||
b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw
|
||||
MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i
|
||||
YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT
|
||||
aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ
|
||||
jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp
|
||||
xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp
|
||||
1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG
|
||||
snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ
|
||||
U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8
|
||||
9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E
|
||||
BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B
|
||||
AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz
|
||||
yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE
|
||||
38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP
|
||||
AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad
|
||||
DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME
|
||||
HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,13 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD
|
||||
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
|
||||
A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
|
||||
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
|
||||
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
|
||||
AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi
|
||||
QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR
|
||||
HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
|
||||
BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D
|
||||
9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8
|
||||
p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD
|
||||
-----END CERTIFICATE-----
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">遥控手表</string>
|
||||
</resources>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user