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:
xmz7891
2026-10-06 19:01:05 +08:00
commit b62ac865ea
3195 changed files with 203292 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.lanremote.diag"
android:versionCode="2"
android:versionName="1.1.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" />
<!--
读取 WiFi 名称(SSID) 在 Android 8.1+ 上被系统限制为需要定位权限。
只为显示"当前连的是哪个 WiFi",用于排查是否和电脑同一网络。
拒绝也行,报告里会写「需要定位权限才能读取」。
-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<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">
<activity
android:name=".DiagActivity"
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>
+126
View File
@@ -0,0 +1,126 @@
# 构建「遥控诊断」APK(aapt2 + javac + d8 + zipalign + apksigner,不需要 Gradle)
# 用法: powershell -File diag\build_diag_apk.ps1
#
# 复用主程序的 Json.java / DiscoveryCore.java(纯 Java,无 Android 依赖),
# 这样诊断用的自动发现逻辑和主程序是**同一份代码**,不会出现"诊断能通、
# 主程序不通"的假象。
$ErrorActionPreference = "Continue"
$diag = $PSScriptRoot # LanRemote/diag
$root = Split-Path -Parent $diag # 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"
$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 $diag "build"
$outDir = Join-Path $root "dist"
$apkName = "LanRemote-Diag-1.1.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" }
}
$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
Step "1/6 aapt2 compile 资源"
$compiled = Join-Path $work "res.zip"
& $aapt2 compile --dir (Join-Path $diag "res") -o $compiled
if ($LASTEXITCODE -ne 0) { throw "aapt2 compile 失败" }
Step "2/6 aapt2 link"
$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 $diag "AndroidManifest.xml") `
-R $compiled `
--java $genDir `
--min-sdk-version 24 `
--target-sdk-version 34 `
--version-code 2 `
--version-name "1.1.0" `
--auto-add-overlay
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
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 $diag "src") |
ForEach-Object { $_.FullName }
# 复用主程序的纯 Java 代码
foreach ($shared in @("Json.java", "B64.java", "DiscoveryCore.java", "Discovery.java")) {
$javaSources += (Join-Path $root "android\src\com\lanremote\watch\$shared")
}
$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 编译失败" }
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"
Write-Host ("classes.dex 大小 {0:N1} KB" -f ((Get-Item $dex).Length / 1KB))
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 失败" }
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
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 与主程序同风格:纯蓝圆角方块 + 白色图形(诊断用图形是"放大镜+信号") -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#185CE8"
android:pathData="M11,2 L37,2 A9,9 0 0 1 46,11 L46,37 A9,9 0 0 1 37,46 L11,46 A9,9 0 0 1 2,37 L2,11 A9,9 0 0 1 11,2 Z" />
<!-- 放大镜圆环 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M21,11 A10,10 0 1 0 21,31 A10,10 0 1 0 21,11 Z M21,15 A6,6 0 1 1 21,27 A6,6 0 1 1 21,15 Z" />
<!-- 手柄 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M28.5,28.5 L35,35 L38,32 L31.5,25.5 Z" />
</vector>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">遥控诊断</string>
</resources>
@@ -0,0 +1,669 @@
package com.lanremote.diag;
import android.Manifest;
import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
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.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.InputType;
import android.util.DisplayMetrics;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import com.lanremote.watch.Discovery;
import com.lanremote.watch.DiscoveryCore;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* 遥控诊断工具。
*
* 目的:把「为什么连不上」需要的信息一次性收集出来,一键复制,发给开发者。
*
* 查找策略(两层):
* 1. UDP 广播(含本网段定向广播)
* 2. 广播没结果就自动切**子网 TCP 扫描** —— 广播被路由器/防火墙拦掉时
* 这是唯一还能找到电脑的办法
*
* 只收集排障必需的项。刻意不收集:IMEI、手机号、联系人、短信、相册、
* 位置坐标、账号。全部内容显示在屏幕上。
*/
public class DiagActivity extends Activity {
private static final int REQ_LOCATION = 3001;
private static final String MAIN_APP = "com.lanremote.watch";
private TextView reportView;
private TextView statusView;
private EditText ipInput;
private final Handler ui = new Handler(Looper.getMainLooper());
private boolean scanning = false;
private String lastReport = "";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().setBackgroundDrawable(
new android.graphics.drawable.ColorDrawable(0xFFF5F7FA));
buildUi();
requestLocationIfNeeded();
runScan();
}
// ==================================================================
// 界面
// ==================================================================
private int dp(float value) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value,
getResources().getDisplayMetrics());
}
private void buildUi() {
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(0xFFF5F7FA);
root.setPadding(dp(8), dp(8), dp(8), dp(8));
TextView title = new TextView(this);
title.setText("遥控诊断");
title.setTextColor(0xFF1F2328);
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
title.setGravity(Gravity.CENTER);
root.addView(title);
statusView = new TextView(this);
statusView.setText("准备中…");
statusView.setTextColor(0xFF656D76);
statusView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
statusView.setGravity(Gravity.CENTER);
statusView.setPadding(0, dp(2), 0, dp(6));
root.addView(statusView);
LinearLayout buttons = new LinearLayout(this);
buttons.setOrientation(LinearLayout.HORIZONTAL);
buttons.addView(makeButton("重新检测", 0xFF185CE8, new Runnable() {
public void run() {
runScan();
}
}), new LinearLayout.LayoutParams(0, dp(52), 1f));
buttons.addView(makeButton("复制全部", 0xFF1A7F37, new Runnable() {
public void run() {
copyReport(true);
}
}), new LinearLayout.LayoutParams(0, dp(52), 1f));
root.addView(buttons);
LinearLayout manual = new LinearLayout(this);
manual.setOrientation(LinearLayout.HORIZONTAL);
manual.setPadding(0, dp(6), 0, dp(6));
ipInput = new EditText(this);
ipInput.setHint("手动测一个 IP,如 192.168.10.103");
ipInput.setHintTextColor(0xFF656D76);
ipInput.setTextColor(0xFF1F2328);
ipInput.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
ipInput.setInputType(InputType.TYPE_CLASS_TEXT
| InputType.TYPE_TEXT_VARIATION_URI);
ipInput.setSingleLine(true);
ipInput.setBackgroundColor(0xFFFFFFFF);
ipInput.setPadding(dp(8), dp(8), dp(8), dp(8));
manual.addView(ipInput, new LinearLayout.LayoutParams(0, dp(48), 1f));
manual.addView(makeButton("测一下", 0xFFE0E4E9, new Runnable() {
public void run() {
testManualIp();
}
}), new LinearLayout.LayoutParams(dp(78), dp(48)));
root.addView(manual);
ScrollView scroll = new ScrollView(this);
reportView = new TextView(this);
reportView.setTextColor(0xFF1F2328);
reportView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11);
reportView.setTypeface(android.graphics.Typeface.MONOSPACE);
reportView.setTextIsSelectable(true);
reportView.setPadding(dp(8), dp(8), dp(8), dp(8));
reportView.setText("正在采集…");
scroll.addView(reportView);
root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
setContentView(root);
}
private Button makeButton(String text, int color, final Runnable task) {
Button button = new Button(this);
button.setText(text);
button.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
button.setTextColor(readableOn(color));
button.setAllCaps(false);
android.graphics.drawable.GradientDrawable bg =
new android.graphics.drawable.GradientDrawable();
bg.setColor(color);
bg.setCornerRadius(dp(8));
bg.setStroke(Math.max(1, dp(1)), 0xFFD0D7DE);
button.setBackground(bg);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
task.run();
}
});
return button;
}
/** 按底色亮度自动选白字还是黑字。 */
private static int readableOn(int background) {
double r = (background >> 16) & 0xFF;
double g = (background >> 8) & 0xFF;
double b = background & 0xFF;
double luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0;
return luminance < 0.62 ? 0xFFFFFFFF : 0xFF1F2328;
}
private void requestLocationIfNeeded() {
if (Build.VERSION.SDK_INT < 23) {
return;
}
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(
new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, REQ_LOCATION);
}
}
// ==================================================================
// 采集
// ==================================================================
private void runScan() {
if (scanning) {
return;
}
scanning = true;
statusView.setText("正在检测…(广播 4 秒 + 必要时子网扫描)");
reportView.setText("正在采集,请稍候…\n\n先跑一次 UDP 广播;如果没结果,"
+ "会自动扫描整个网段的 TCP 8765 端口。最多约 20 秒。");
final String manualIp = ipInput.getText().toString().trim();
new Thread(new Runnable() {
@Override
public void run() {
final String report = buildReport(manualIp, true);
ui.post(new Runnable() {
@Override
public void run() {
scanning = false;
lastReport = report;
reportView.setText(report);
statusView.setText("检测完成,已自动复制到剪贴板");
copyReport(false);
}
});
}
}, "diag").start();
}
private void testManualIp() {
final String ip = ipInput.getText().toString().trim();
if (ip.isEmpty()) {
toast("先填一个 IP");
return;
}
statusView.setText("正在测试 " + ip + " …");
new Thread(new Runnable() {
@Override
public void run() {
final String result = manualIpTest(ip);
ui.post(new Runnable() {
@Override
public void run() {
statusView.setText("测试完成,已追加到报告并复制");
lastReport = lastReport + "\n" + result + "\n";
reportView.setText(lastReport);
copyReport(false);
}
});
}
}, "diag-manual").start();
}
private void copyReport(boolean manual) {
if (lastReport == null || lastReport.isEmpty()) {
if (manual) {
toast("还没有报告,先点「重新检测」");
}
return;
}
try {
ClipboardManager manager =
(ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (manager != null) {
manager.setPrimaryClip(ClipData.newPlainText("遥控诊断报告", lastReport));
if (manual) {
toast("已复制 " + lastReport.length() + " 个字符");
statusView.setText("已复制到剪贴板,去聊天窗口粘贴发送即可");
}
}
} catch (Exception e) {
toast("复制失败:" + e.getMessage());
}
}
private void toast(String text) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
}
// ==================================================================
// 报告
// ==================================================================
private String buildReport(String manualIp, boolean deepScan) {
StringBuilder sb = new StringBuilder();
sb.append("===== 遥控诊断报告 =====\n");
sb.append("报告版本: 2\n");
sb.append("生成时间: ").append(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss",
Locale.getDefault()).format(new Date())).append("\n\n");
appendDevice(sb);
appendApp(sb);
appendPermissions(sb);
Discovery.LocalNet net = appendNetwork(sb);
List<DiscoveryCore.Found> found = appendDiscovery(sb, net, deepScan);
appendTcpTests(sb, found);
if (manualIp != null && !manualIp.isEmpty()) {
sb.append("\n").append(manualIpTest(manualIp)).append("\n");
}
sb.append("\n===== 报告结束 =====\n");
return sb.toString();
}
private void appendDevice(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("产品名: ").append(s(Build.PRODUCT)).append("\n");
sb.append("硬件: ").append(s(Build.HARDWARE)).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("(读取失败: ").append(t).append(")\n");
}
try {
PackageManager pm = 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 {
DisplayMetrics m = 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 void appendApp(StringBuilder sb) {
sb.append("--- 应用 ---\n");
try {
PackageManager pm = getPackageManager();
String self = getPackageName();
sb.append("本诊断包名: ").append(self).append("\n");
if (Build.VERSION.SDK_INT >= 33) {
PackageInfo info = pm.getPackageInfo(self,
PackageManager.PackageInfoFlags.of(0));
sb.append("诊断版本: ").append(info.versionName)
.append(" (").append(info.getLongVersionCode()).append(")\n");
} else {
PackageInfo info = pm.getPackageInfo(self, 0);
sb.append("诊断版本: ").append(info.versionName)
.append(" (").append(info.versionCode).append(")\n");
}
try {
PackageInfo app = pm.getPackageInfo(MAIN_APP, 0);
sb.append("主程序 com.lanremote.watch: 已安装,版本 ")
.append(app.versionName).append(" (").append(app.versionCode).append(")\n");
} catch (PackageManager.NameNotFoundException e) {
sb.append("主程序 com.lanremote.watch: **未安装**\n");
}
} catch (Throwable t) {
sb.append("(读取失败: ").append(t).append(")\n");
}
sb.append("\n");
}
/**
* 权限状态。
*
* 注意分两栏:诊断 App 自己的,和**主程序**的。
* 之前只报诊断自己的,会让人误以为主程序也没权限 —— 这是上一版的疏漏。
*/
private void appendPermissions(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,
};
PackageManager pm = getPackageManager();
boolean mainInstalled = true;
try {
pm.getPackageInfo(MAIN_APP, 0);
} catch (PackageManager.NameNotFoundException e) {
mainInstalled = false;
}
sb.append(String.format("%-32s %-10s %s\n", "权限", "诊断", "主程序"));
for (String name : names) {
String diagState;
if (Build.VERSION.SDK_INT < 23) {
diagState = "安装时";
} else {
diagState = checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED
? "已授予" : "未授予";
}
String mainState;
if (!mainInstalled) {
mainState = "未安装";
} else if (Build.VERSION.SDK_INT < 23) {
mainState = "安装时";
} else {
mainState = pm.checkPermission(name, MAIN_APP)
== PackageManager.PERMISSION_GRANTED ? "已授予" : "未授予";
}
sb.append(String.format("%-32s %-10s %s\n", shortName(name), diagState, mainState));
}
sb.append("说明: API < 33 时 READ_MEDIA_* 不存在,显示「未授予」是正常的,\n");
sb.append(" 老系统只看 READ_EXTERNAL_STORAGE。\n\n");
}
private Discovery.LocalNet appendNetwork(StringBuilder sb) {
sb.append("--- 网络 ---\n");
try {
ConnectivityManager cm = (ConnectivityManager)
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) 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) 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;
}
/** 第一层:UDP 广播。有需要时第二层:子网 TCP 扫描。 */
private List<DiscoveryCore.Found> appendDiscovery(StringBuilder sb,
Discovery.LocalNet net,
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(this, 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");
ui.post(new Runnable() {
@Override
public void run() {
statusView.setText("广播没结果,正在扫描整个网段的 8765 端口…");
}
});
try {
long start = System.currentTimeMillis();
List<String> open = Discovery.scanSubnet(this, 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(" -> 可以先在上面手动填电脑 IP 点「测一下」进一步定位\n");
}
sb.append("\n");
return found;
}
private 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 r = DiscoveryCore.pingDetailed(item.host, item.port, 4000);
sb.append(" ").append(item.host).append(":").append(item.port)
.append(" -> ").append(r.describe()).append("\n");
}
sb.append("\n");
}
private String manualIpTest(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");
sb.append(" 去电脑上双击 LanRemote-Agent.exe 即可。\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(" (手机和电脑都走无线时,这个最常见)\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);
}
}