package com.lanremote.watch; import android.Manifest; import android.animation.ValueAnimator; import android.app.Activity; import android.app.AlertDialog; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.drawable.GradientDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.provider.OpenableColumns; import android.text.InputType; import android.util.TypedValue; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.HorizontalScrollView; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * 手表控制端主界面。 * * 界面按 490x602 的小屏手表定制:两列大按钮、最小 58dp 点击高度、 * 15sp 以上字号,整页可滚动,保证在极小屏幕上也能准确点按。 */ public class MainActivity extends Activity implements RemoteClient.Listener { // ---------------- 配色 ---------------- // ---- 浅色配色 ---- private static final int C_BG = 0xFFF5F7FA; // 页面底色(浅灰) private static final int C_CARD = 0xFFFFFFFF; // 卡片 / 按钮底色 private static final int C_FG = 0xFF1F2328; // 主文字(近黑) private static final int C_ACCENT = 0xFF185CE8; // 与图标一致的蓝 private static final int C_OK = 0xFF1A7F37; private static final int C_WARN = 0xFF9A6700; private static final int C_DANGER = 0xFFCF222E; private static final int C_DIM = 0xFF656D76; private static final int C_BORDER = 0xFFD0D7DE; // 描边 // ---- 液态玻璃 ---- private static final int GLASS_BG_TOP = 0xFFE8F0FF; // 背景渐变·上 private static final int GLASS_BG_MID = 0xFFF1EBFF; // 背景渐变·中 private static final int GLASS_BG_BOT = 0xFFFFF0F6; // 背景渐变·下 private static final int GLASS_FILL = 0xB3FFFFFF; // 玻璃面(70% 白) private static final int GLASS_FILL_SOFT = 0x8CFFFFFF; // 更透的玻璃 private static final int GLASS_FILL_SOLID = 0xE6FFFFFF; // 实一点(输入框/日志) private static final int GLASS_EDGE = 0x99FFFFFF; // 玻璃亮边 private static final int GLASS_EDGE_DIM = 0x40FFFFFF; // 弱一点的边 /** 中转服务器地址(跨网络用)。空 = 只走局域网。 */ /** * 内置的默认中转地址。 * * 服务器端已经架在随身 WiFi 上并用 Cloudflare 隧道暴露成 * relay.xxmxxs.top,所以手表装完不用填任何地址就能连。 * 用户在「中转设置」里随时能改。 */ private static final String DEFAULT_RELAY_URL = "https://relay.xxmxxs.top"; private String relayUrl() { String saved = getSharedPreferences("lanremote", MODE_PRIVATE) .getString("relay_url", DEFAULT_RELAY_URL); // 没设过(或设成了空)就用内置默认值 —— 开箱即用, // 装完 App 不用填任何地址就能连。 return (saved == null || saved.trim().isEmpty()) ? DEFAULT_RELAY_URL : saved.trim(); } private void setRelayUrl(String url) { getSharedPreferences("lanremote", MODE_PRIVATE).edit() .putString("relay_url", url == null ? "" : url.trim()) .apply(); } /** 让用户填中转地址。填了就能跨网络控制。 */ private void promptRelayUrl() { final EditText input = new EditText(this); input.setHint("https://relay.你的域名"); input.setText(relayUrl()); input.setTextColor(C_FG); input.setHintTextColor(C_DIM); input.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13); input.setSingleLine(true); input.setBackground(rounded(C_CARD, 7)); input.setPadding(dp(10), dp(10), dp(10), dp(10)); LinearLayout box = new LinearLayout(this); box.setOrientation(LinearLayout.VERTICAL); box.setPadding(dp(14), dp(8), dp(14), dp(4)); TextView hint = new TextView(this); hint.setText("填了就走中转服务器,能在任意网络下控制电脑。\n" + "留空则只在同一个局域网内可用。"); hint.setTextColor(C_DIM); hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11); hint.setPadding(0, 0, 0, dp(8)); box.addView(hint); box.addView(input, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); new AlertDialog.Builder(this) .setTitle("中转服务器地址") .setView(box) .setPositiveButton("保存", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface dialog, int which) { String url = input.getText().toString().trim(); setRelayUrl(url); if (url.isEmpty()) { toast("已清空:以后只走局域网"); } else { toast("已保存中转地址"); appendLog("中转地址:" + url); } } }) .setNeutralButton("清空", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface dialog, int which) { setRelayUrl(""); toast("已清空"); } }) .setNegativeButton("取消", null) .show(); } private static final int REQ_IMAGE = 1001; private static final int REQ_AUDIO = 1002; private static final int REQ_FILE = 1003; private static final int REQ_PERMS = 2001; private RemoteClient client; private DeviceStore store; private Handler ui; private final ExecutorService io = Executors.newSingleThreadExecutor(); private FrameLayout root; private View connectPanel; private LinearLayout controlPanel; private FrameLayout mirrorPanel; private ImageView mirrorImage; private TextView mirrorStatus; private LinearLayout deviceBox; // 横屏/竖屏自适应用的网格参数(adaptGridToScreen 里算) private int gridColumns = 3; private int cellHeightDp = 58; private float cellTextSp = 13f; // 待排版的按钮队列,flushGrid() 里按列数切成行 private final java.util.List cellQueue = new java.util.ArrayList(); // 正在连接中(避免重复点) private volatile boolean busy; // 屏幕镜像是否在推流 private volatile boolean mirrorStreaming; // 正在接收的文件缓冲 + 文件名 private ByteArrayOutputStream incoming; private String incomingName = ""; // 屏幕镜像的帧率统计 private int frameCount; private float currentFps; private long fpsWindowStart; // 界面日志缓冲(限制长度,免得越滚越长) private final StringBuilder logBuffer = new StringBuilder(); // ---- 局域网连接页面 ---- private FrameLayout lanPanel; private LinearLayout lanResultBox; private EditText lanIpInput; private EditText lanPortInput; private EditText lanCodeInput; private TextView lanStatus; private volatile boolean lanSearching; // ---- 服务器延迟 ---- private TextView serverStatus; private TextView statusView; private TextView logView; private TextView serverLabel; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); store = new DeviceStore(this); ui = new Handler(Looper.getMainLooper()); client = new RemoteClient(); client.setDevice(store.deviceId(), store.deviceName()); root = new FrameLayout(this); root.setBackgroundColor(C_BG); // 三块面板都先建好,之后按需要显示哪一块。 // 注意顺序:控制页在最上面,镜像页居中,连接页压在最上层。 buildConnectPanel(); buildControlPanel(); // ⚠ initTabs() 必须在这里调 —— 它负责填 tabDefs 并把 10 个标签 // 塞进 tabBar。漏了这一步的后果是**连上后一片白屏**: // tabDefs == null -> selectTab() 直接 return -> 内容区空白, // 标签栏里只剩下那个滑动指示器(一个孤立的小蓝块)。 // 之前某次批量改代码把这一行删掉了,白屏就是这么来的。 initTabs(); buildMirrorPanel(); buildLanPanel(); root.addView(controlPanel, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); root.addView(mirrorPanel, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); root.addView(connectPanel, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); root.addView(lanPanel, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); controlPanel.setVisibility(View.GONE); mirrorPanel.setVisibility(View.GONE); lanPanel.setVisibility(View.GONE); connectPanel.setVisibility(View.VISIBLE); setContentView(root); // 崩溃兜底要放在界面建好之后(它内部要用 ui) installCrashGuard(); requestStartupPermissions(); refreshDeviceList(); appendLog("ykxmz 手表端已启动"); } private boolean closeLanIfOpen() { if (lanPanel != null && lanPanel.getVisibility() == View.VISIBLE) { hideLanPanel(); return true; } return false; } @Override public void onBackPressed() { // 局域网页面开着的话,先退回首页 if (closeLanIfOpen()) { return; } super.onBackPressed(); } @Override protected void onDestroy() { super.onDestroy(); try { client.close(); } catch (Exception ignored) { } io.shutdownNow(); } // ================================================================== // 尺寸辅助 // ================================================================== private int dp(float value) { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, getResources().getDisplayMetrics()); } private int sp(float value) { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, getResources().getDisplayMetrics()); } // ================================================================== // 液态玻璃 设计系统 // ================================================================== /** 背景:柔和三段渐变,像玻璃后面透出来的壁纸。 */ private GradientDrawable glassBackground() { GradientDrawable drawable = new GradientDrawable( GradientDrawable.Orientation.TL_BR, new int[]{GLASS_BG_TOP, GLASS_BG_MID, GLASS_BG_BOT}); return drawable; } /** 一块玻璃:半透明白 + 亮边。 */ private GradientDrawable glass(float radiusDp) { return glass(GLASS_FILL, radiusDp, GLASS_EDGE); } private GradientDrawable glass(int fill, float radiusDp, int edge) { GradientDrawable drawable = new GradientDrawable(); drawable.setShape(GradientDrawable.RECTANGLE); drawable.setColor(fill); drawable.setCornerRadius(dp(radiusDp)); // 亮边是"玻璃厚度"的关键,没有它看着就是一块白纸 drawable.setStroke(Math.max(1, dp(1)), edge); return drawable; } /** 按钮的玻璃底:顶部略亮,模拟反光。 */ private GradientDrawable glassButton(int tint, float radiusDp) { GradientDrawable drawable = new GradientDrawable( GradientDrawable.Orientation.TOP_BOTTOM, new int[]{blend(tint, 0xFFFFFFFF, 0.10f), tint}); drawable.setCornerRadius(dp(radiusDp)); drawable.setStroke(Math.max(1, dp(1)), GLASS_EDGE); return drawable; } /** 把颜色往另一个颜色混一点。amount=0 原色,1 完全是另一个色。 */ private static int blend(int base, int other, float amount) { float keep = 1f - amount; int a = (int) (((base >> 24) & 0xFF) * keep + ((other >> 24) & 0xFF) * amount); int r = (int) (((base >> 16) & 0xFF) * keep + ((other >> 16) & 0xFF) * amount); int g = (int) (((base >> 8) & 0xFF) * keep + ((other >> 8) & 0xFF) * amount); int b = (int) ((base & 0xFF) * keep + (other & 0xFF) * amount); return (a << 24) | (r << 16) | (g << 8) | b; } /** * 按压动画:缩到 0.94,松手带一点回弹。 * 返回 false 不吞事件,OnClickListener 照常触发。 */ private void attachPress(final View view) { if (view == null) { return; } view.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: v.animate().cancel(); v.animate().scaleX(0.94f).scaleY(0.94f) .setDuration(90) .setInterpolator(new android.view.animation.DecelerateInterpolator()) .start(); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: v.animate().cancel(); v.animate().scaleX(1f).scaleY(1f) .setDuration(280) .setInterpolator( new android.view.animation.OvershootInterpolator(2.2f)) .start(); break; } return false; } }); } /** 淡入 + 轻微上移(面板/内容出现时用)。 */ private void animateIn(View view, float fromDp, long duration) { if (view == null) { return; } view.setAlpha(0f); view.setTranslationY(dp(fromDp)); view.animate().alpha(1f).translationY(0f) .setDuration(duration) .setInterpolator(new android.view.animation.DecelerateInterpolator()) .start(); } /** 淡出 + 轻微下移(面板关闭时用)。 */ private void animateOut(final View view, float toDp, long duration, final Runnable after) { if (view == null) { if (after != null) { after.run(); } return; } view.animate().alpha(0f).translationY(dp(toDp)) .setDuration(duration) .setInterpolator(new android.view.animation.AccelerateInterpolator()) .withEndAction(new Runnable() { @Override public void run() { if (after != null) { after.run(); } } }) .start(); } private GradientDrawable rounded(int fill, float radiusDp) { GradientDrawable drawable = new GradientDrawable(); drawable.setColor(fill); drawable.setCornerRadius(dp(radiusDp)); // 浅色主题下白按钮和浅灰底几乎同色,加一圈淡描边区分开 drawable.setStroke(Math.max(1, dp(1)), C_BORDER); return drawable; } /** 按底色亮度自动选白字还是黑字,避免蓝底配黑字看不清。 */ 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 : C_FG; } // ================================================================== // 连接面板 // ================================================================== private void buildConnectPanel() { ScrollView scroll = new ScrollView(this); scroll.setBackgroundColor(0x00000000); LinearLayout box = new LinearLayout(this); box.setOrientation(LinearLayout.VERTICAL); box.setBackgroundColor(0x00000000); box.setPadding(dp(10), dp(12), dp(10), dp(12)); scroll.addView(box, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); connectPanel = scroll; TextView title = new TextView(this); title.setText("遥控手表"); title.setTextColor(C_FG); title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20); title.setGravity(Gravity.CENTER); box.addView(title); TextView hint = new TextView(this); hint.setText("和电脑连同一个 WiFi,不用输 IP"); hint.setTextColor(C_DIM); hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); hint.setGravity(Gravity.CENTER); hint.setPadding(0, dp(2), 0, dp(10)); box.addView(hint); // 连不上?—— 直接在这里做设备诊断。 // 用户原话:"我就是连不上才开设备诊断,连的上要设备诊断干嘛", // 所以它属于首页,不属于功能标签页。 // 中转地址:跨网络用。放首页是因为这是"连不上"的常见原因之一。 Button relayButton = bigButton("中转设置(跨网络用)", C_ACCENT, 14); LinearLayout.LayoutParams relayParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(52)); relayParams.setMargins(0, 0, 0, dp(8)); relayButton.setLayoutParams(relayParams); relayButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { promptRelayUrl(); } }); box.addView(relayButton); // 输入配对码直接连对应电脑。 // 不再需要"先搜局域网 / 手动填 IP"那一套 —— // 现在统一经中转,配对码就是找电脑的唯一凭据。 // 局域网连接:手表和电脑在同一个 WiFi 时走这条,不经过中转。 // 用户要求:"在首页没连之前可以加一个进入局域网连接页面, // 就可以用局域网进行连接,不经过中转服务器" Button lanButton = bigButton("局域网连接(同一个 WiFi)", C_ACCENT, 15); LinearLayout.LayoutParams lanParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(52)); lanParams.setMargins(0, 0, 0, dp(8)); lanButton.setLayoutParams(lanParams); lanButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { showLanPanel(); } }); box.addView(lanButton); Button codeButton = bigButton("输入配对码连接", C_OK, 15); LinearLayout.LayoutParams codeParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(52)); codeParams.setMargins(0, 0, 0, dp(8)); codeButton.setLayoutParams(codeParams); codeButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { promptPairingCode(); } }); box.addView(codeButton); // 服务器延迟:连不上中转的时候,这一条最有用 LinearLayout serverRow = new LinearLayout(this); serverRow.setOrientation(LinearLayout.HORIZONTAL); serverRow.setGravity(Gravity.CENTER_VERTICAL); serverStatus = new TextView(this); serverStatus.setTextColor(C_DIM); serverStatus.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); serverStatus.setText("服务器:还没测"); LinearLayout.LayoutParams ssParams = new LinearLayout.LayoutParams( 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); serverStatus.setLayoutParams(ssParams); serverRow.addView(serverStatus); Button pingButton = smallButton("检查服务器", C_CARD, new Runnable() { @Override public void run() { checkServer(); } }); serverRow.addView(pingButton); box.addView(serverRow); statusView = new TextView(this); statusView.setTextColor(C_DIM); statusView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); statusView.setPadding(0, dp(6), 0, dp(6)); box.addView(statusView); TextView savedTitle = new TextView(this); savedTitle.setText("已配对的电脑(点一下直连)"); savedTitle.setTextColor(C_FG); savedTitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13); savedTitle.setPadding(0, dp(12), 0, dp(6)); box.addView(savedTitle); deviceBox = new LinearLayout(this); deviceBox.setOrientation(LinearLayout.VERTICAL); box.addView(deviceBox); } // ================================================================== /** 连不上时:广播请求,让电脑把网络自检窗口打开。 */ // 自动搜索电脑(不用输 IP) // ================================================================== /** 找出这台电脑是否已经配对过。 */ private String findSavedToken(String host, int port) { for (Map device : store.devices()) { if (host.equals(Json.str(device, "host", "")) && port == Json.integer(device, "port", 0)) { String token = Json.str(device, "token", ""); if (!token.isEmpty()) { return token; } } } return null; } private LinearLayout labeled(String label, View field) { LinearLayout box = new LinearLayout(this); box.setOrientation(LinearLayout.VERTICAL); TextView text = new TextView(this); text.setText(label); text.setTextColor(C_DIM); text.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); box.addView(text); box.addView(field); box.setPadding(0, dp(4), 0, dp(4)); return box; } private EditText makeInput(String hint, int inputType) { EditText edit = new EditText(this); edit.setHint(hint); edit.setHintTextColor(C_DIM); edit.setTextColor(C_FG); edit.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); edit.setInputType(inputType); edit.setSingleLine(true); edit.setBackground(rounded(C_CARD, 8)); edit.setPadding(dp(10), dp(10), dp(10), dp(10)); edit.setLayoutParams(new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(48))); return edit; } /** * 兜底:任何线程抛出未捕获异常都不让它把整个 App 干掉。 * * 用户的要求是「不管干嘛,程序都要开着」—— * 后台线程一个 NPE 就闪退,看起来就像"程序自己关了"。 */ private void installCrashGuard() { Thread.setDefaultUncaughtExceptionHandler( new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread thread, final Throwable error) { try { final String detail = String.valueOf(error); ui.post(new Runnable() { @Override public void run() { try { appendLog("已拦截内部错误:" + detail); toast("出了点小问题,程序继续运行"); } catch (Throwable ignored) { // 兜底里再出错就彻底放弃提示 } } }); } catch (Throwable ignored) { // 忽略 } // 故意不交给默认处理器:交给它系统就按崩溃处理了 } }); } private void refreshDeviceList() { deviceBox.removeAllViews(); List> devices = store.devices(); if (devices.isEmpty()) { TextView empty = new TextView(this); empty.setText("(还没有配对过任何电脑)"); empty.setTextColor(C_DIM); empty.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); deviceBox.addView(empty); return; } for (final Map device : devices) { final String key = Json.str(device, "key", ""); final String host = Json.str(device, "host", ""); final int port = Json.integer(device, "port", RemoteClient.DEFAULT_PORT); final String token = Json.str(device, "token", ""); final String label = Json.str(device, "label", host); // 保存的地址可能已经过期(电脑换了网络/网段)。 // 和手表不在同一网段时直接在按钮上标出来 —— // 不然用户点下去只会等 6 秒直连失败 + 40 秒中转超时, // 完全不知道问题出在哪儿。 String stale = ""; if (!host.isEmpty() && !hostInMySubnet(host)) { stale = " ⚠ 地址可能已过期"; } Button button = bigButton(label + "\n" + host + ":" + port + stale, C_CARD, 14); button.setGravity(Gravity.CENTER_VERTICAL | Gravity.LEFT); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(58)); params.setMargins(0, dp(3), 0, dp(3)); button.setLayoutParams(params); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { quickConnect(host, port, token, key); } }); button.setOnLongClickListener(new View.OnLongClickListener() { @Override public boolean onLongClick(View view) { confirmRemoveDevice(key, label); return true; } }); deviceBox.addView(button); } } private void confirmRemoveDevice(final String key, String label) { new AlertDialog.Builder(this) .setTitle("删除配对") .setMessage("删除「" + label + "」?下次需要重新输入配对码。") .setPositiveButton("删除", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface dialog, int which) { store.removeDevice(key); refreshDeviceList(); appendLog("已删除配对记录"); } }) .setNegativeButton("取消", null) .show(); } // ================================================================== // 控制面板 // ================================================================== private void buildControlPanel() { controlPanel = new LinearLayout(this); controlPanel.setOrientation(LinearLayout.VERTICAL); controlPanel.setBackgroundColor(C_BG); controlPanel.setPadding(dp(8), dp(8), dp(8), dp(8)); adaptGridToScreen(); // 顶部:服务器名 + 断开 LinearLayout top = new LinearLayout(this); top.setOrientation(LinearLayout.HORIZONTAL); top.setGravity(Gravity.CENTER_VERTICAL); serverLabel = new TextView(this); serverLabel.setText("已连接"); serverLabel.setTextColor(C_OK); serverLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); LinearLayout.LayoutParams labelParams = new LinearLayout.LayoutParams( 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); serverLabel.setLayoutParams(labelParams); top.addView(serverLabel); Button disconnect = new Button(this); disconnect.setText("断开"); disconnect.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13); disconnect.setTextColor(readableOn(C_DANGER)); disconnect.setBackground(rounded(C_DANGER, 8)); LinearLayout.LayoutParams disconnectParams = new LinearLayout.LayoutParams(dp(74), dp(42)); disconnect.setLayoutParams(disconnectParams); disconnect.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { doDisconnect(); } }); top.addView(disconnect); top.setBackground(glass(16)); top.setPadding(dp(10), dp(8), dp(8), dp(8)); controlPanel.addView(top); // ---- 分页标签(玻璃容器 + 滑动指示器)---- HorizontalScrollView tabScroll = new HorizontalScrollView(this); tabScroll.setHorizontalScrollBarEnabled(false); FrameLayout tabWrap = new FrameLayout(this); tabWrap.setBackground(glass(18)); tabWrap.setPadding(dp(4), dp(4), dp(4), dp(4)); tabIndicator = new View(this); tabIndicator.setBackground(glassButton(C_ACCENT, 14)); tabIndicator.setLayoutParams(new FrameLayout.LayoutParams(dp(60), dp(34))); tabWrap.addView(tabIndicator); tabBar = new LinearLayout(this); tabBar.setOrientation(LinearLayout.HORIZONTAL); tabWrap.addView(tabBar, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); tabScroll.addView(tabWrap); controlPanel.addView(tabScroll, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(48))); // ---- 分页内容 ---- tabContent = new LinearLayout(this); tabContent.setOrientation(LinearLayout.VERTICAL); tabContent.setPadding(0, dp(6), 0, dp(6)); ScrollView contentScroll = new ScrollView(this); contentScroll.addView(tabContent, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); controlPanel.addView(contentScroll, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); // 日志 logView = new TextView(this); logView.setTextColor(C_DIM); logView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11); logView.setBackground(glass(GLASS_FILL_SOLID, 14, GLASS_EDGE_DIM)); logView.setPadding(dp(8), dp(6), dp(8), dp(6)); logView.setMaxLines(4); controlPanel.addView(logView, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); } /** * 按屏幕实际宽度决定列数、按钮高度与字号。 * * 目标是 490x602 的华强北手表:这类设备 density 常常是 1.0~2.0, * 换算成 dp 可能只有 245dp 宽。宽度不足 200dp 时两列按钮会挤到 * 文字换行,因此退化为单列并把按钮加高,保证手指能准确点中。 */ private void adaptGridToScreen() { android.util.DisplayMetrics metrics = getResources().getDisplayMetrics(); int widthDp = (int) (metrics.widthPixels / metrics.density); int heightDp = (int) (metrics.heightPixels / metrics.density); if (widthDp < 200) { // 极窄屏:单列,按钮最高 gridColumns = 1; cellHeightDp = 64; cellTextSp = 16f; } else if (widthDp < 300) { gridColumns = 2; cellHeightDp = 60; cellTextSp = 14f; } else if (widthDp < 330) { gridColumns = 2; cellHeightDp = 64; cellTextSp = 15f; } else { // 目标真机 M60T 是 492x602px / density 1.4125 -> 348x426dp。 // 两列时 24 个按钮要滚 474dp(近两屏);三列只需 8 行,滚动减到约 200dp。 // 三列下每个按钮仍有约 105dp 宽,最长文字「取电脑剪贴板」约 78dp,余量充足。 gridColumns = 3; cellHeightDp = 58; cellTextSp = 13f; } if (heightDp < 320 && gridColumns == 2) { cellHeightDp = 56; } } private void addRow(LinearLayout parent, View left, View right) { // 只入队,真正的行切分在 flushGrid() 里按 gridColumns 统一做。 // 允许传 null:分页里某一页的按钮数可能是奇数。 if (left != null) { cellQueue.add(left); } if (right != null) { cellQueue.add(right); } } /** 把入队的按钮按当前列数切成若干行。 */ private void flushGrid(LinearLayout parent) { int columns = Math.max(1, gridColumns); for (int index = 0; index < cellQueue.size(); index += columns) { LinearLayout row = new LinearLayout(this); row.setOrientation(LinearLayout.HORIZONTAL); for (int slot = 0; slot < columns; slot++) { int position = index + slot; LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( 0, dp(cellHeightDp), 1f); params.setMargins(dp(3), dp(3), dp(3), dp(3)); if (position < cellQueue.size()) { View cell = cellQueue.get(position); cell.setLayoutParams(params); row.addView(cell); } else { View spacer = new View(this); spacer.setLayoutParams(params); row.addView(spacer); } } parent.addView(row); } cellQueue.clear(); } private Button action(String text, final Runnable task) { Button button = new Button(this); button.setText(text); button.setTextSize(TypedValue.COMPLEX_UNIT_SP, cellTextSp); button.setTextColor(C_FG); button.setBackground(glassButton(GLASS_FILL_SOFT, 16)); button.setAllCaps(false); button.setPadding(dp(4), 0, dp(4), 0); attachPress(button); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { if (!ensureConnected()) { return; } task.run(); } }); return button; } /** 简单的列表选择框。 */ private interface Chooser { void onPick(int index); } private void showChooser(String title, List items, final Chooser chooser) { if (isFinishing() || isDestroyed()) { return; // 界面已经关了就别弹,否则 BadTokenException } final String[] array = items.toArray(new String[0]); new AlertDialog.Builder(this) .setTitle(title) .setItems(array, new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface dialog, int which) { chooser.onPick(which); } }) .setNegativeButton("取消", null) .show(); } // ================================================================== // 分页 // ================================================================== private static final class Act { final String label; final Runnable task; Act(String label, Runnable task) { this.label = label; this.task = task; } } private static final class TabDef { final String name; final Act[] acts; final String[] groups; // 这个分页要渲染哪些功能分组 TabDef(String name, Act[] acts, String... groups) { this.name = name; this.acts = acts; this.groups = groups; } } private TabDef[] tabDefs; private LinearLayout tabBar; private LinearLayout tabContent; private int currentTab = 0; private Act act(String label, Runnable task) { return new Act(label, task); } /** 定义所有分页。每页最多 7 个按钮,一屏放得下,不用滚。 */ private void initTabs() { tabDefs = new TabDef[]{ new TabDef("媒体", new Act[]{ act("播放/暂停", new Runnable() { public void run() { media("playpause"); } }), act("停止", new Runnable() { public void run() { media("stop"); } }), act("上一曲", new Runnable() { public void run() { media("prev"); } }), act("下一曲", new Runnable() { public void run() { media("next"); } }), act("音量 +", new Runnable() { public void run() { media("volup"); } }), act("音量 -", new Runnable() { public void run() { media("voldown"); } }), act("静音", new Runnable() { public void run() { media("mute"); } }), }, "音量", "媒体"), new TabDef("屏幕", new Act[]{ act("关掉画面", new Runnable() { public void run() { runFeature("close_visuals", Json.obj(), "关掉画面"); } }), act("屏幕镜像", new Runnable() { public void run() { openMirrorPanel(); } }), act("截屏", new Runnable() { public void run() { doScreenshot(); } }), act("投图到电脑", new Runnable() { public void run() { pickFile(REQ_IMAGE, "image/*"); } }), act("放音到电脑", new Runnable() { public void run() { pickFile(REQ_AUDIO, "audio/*"); } }), }, "显示"), new TabDef("文件", new Act[]{ act("发送文件", new Runnable() { public void run() { pickFile(REQ_FILE, "*/*"); } }), act("接收文件", new Runnable() { public void run() { showRemoteFiles(); } }), act("剪贴板→电脑", new Runnable() { public void run() { promptClipboardToPc(); } }), act("取电脑剪贴板", new Runnable() { public void run() { fetchClipboard(); } }), }, "文件"), new TabDef("语音", new Act[]{ act("语音播报", new Runnable() { public void run() { promptSay(); } }), act("音色", new Runnable() { public void run() { pickVoice(); } }), act("朗读剪贴板", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "say_clip"), "朗读剪贴板"); } }), act("电脑音乐", new Runnable() { public void run() { pickLocalAudio(); } }), act("弹通知", new Runnable() { public void run() { promptNotify(); } }), }, "静默"), new TabDef("电脑", new Act[]{ act("快捷命令", new Runnable() { public void run() { pickQuickApp(); } }), act("打开网址", new Runnable() { public void run() { promptOpenUrl(); } }), act("进程管理", new Runnable() { public void run() { pickProcess(); } }), act("性能", new Runnable() { public void run() { showPerf(); } }), act("磁盘", new Runnable() { public void run() { showDisk(); } }), act("电量", new Runnable() { public void run() { showBattery(); } }), }, "查看", "工具"), new TabDef("网络", new Act[]{ act("本机监听端口", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "feature_run", "name", "open_ports", "params", Json.obj()), "监听端口"); } }), act("确认能连上", new Runnable() { public void run() { // 局域网自检已随局域网功能一起移除 statusView.setText("该功能已移除"); } }), }, "网络", "极客"), new TabDef("系统", new Act[]{ act("回退上一步", new Runnable() { public void run() { runFeature("undo_last", Json.obj(), "回退上一步"); } }), act("回退全部", new Runnable() { public void run() { runFeature("undo_all", Json.obj(), "回退全部"); } }), act("看能回退什么", new Runnable() { public void run() { runFeature("undo_list", Json.obj(), "可回退的操作"); } }), act("锁屏", new Runnable() { public void run() { confirmPower("lock", "锁屏"); } }), act("睡眠", new Runnable() { public void run() { confirmPower("sleep", "睡眠"); } }), act("关机", new Runnable() { public void run() { confirmPower("shutdown", "关机"); } }), act("重启", new Runnable() { public void run() { confirmPower("restart", "重启"); } }), act("取消定时关机", new Runnable() { public void run() { cancelShutdown(); } }), act("定时关机", new Runnable() { public void run() { pickShutdownTimer(); } }), }, "系统"), new TabDef("教室", new Act[]{ act("屏蔽游戏网站", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "feature_run", "name", "game_block_common", "params", Json.obj()), "屏蔽游戏网站"); } }), act("结束游戏进程", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "feature_run", "name", "kill_games", "params", Json.obj()), "结束游戏"); } }), act("一键恢复全部", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "feature_run", "name", "restore_all", "params", Json.obj()), "恢复全部限制"); } }), act("看有哪些限制", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "feature_run", "name", "restrict_status", "params", Json.obj()), "限制状态"); } }), }, "教室", "教学"), new TabDef("效率", new Act[]{ act("加待办", new Runnable() { public void run() { promptTwo("加一条待办", "内容", "(第二格留空)", new TwoHandler() { public void onInput(String a, String b) { runFeature("todo_add", Json.obj("text", a), "待办"); } }); } }), act("看所有待办", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "feature_run", "name", "todo_list", "params", Json.obj()), "待办列表"); } }), }, "效率"), new TabDef("娱乐", new Act[]{ act("掷骰子", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "feature_run", "name", "dice", "params", Json.obj()), "掷骰子"); } }), act("抽奖", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "feature_run", "name", "lottery", "params", Json.obj()), "抽奖"); } }), act("抛硬币", new Runnable() { public void run() { sendCommand(Json.obj("cmd", "feature_run", "name", "coin_flip", "params", Json.obj()), "抛硬币"); } }), }, "娱乐"), }; tabBar.removeAllViews(); for (int i = 0; i < tabDefs.length; i++) { final int index = i; TextView tab = new TextView(this); tab.setText(tabDefs[i].name); tab.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13); tab.setGravity(Gravity.CENTER); tab.setPadding(dp(14), dp(8), dp(14), dp(8)); tab.setBackgroundColor(0x00000000); tab.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { selectTab(index); } }); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT); params.setMargins(0, 0, 0, 0); tab.setLayoutParams(params); tabBar.addView(tab); } selectTab(0); } private void selectTab(int index) { if (tabDefs == null || index < 0 || index >= tabDefs.length) { return; } currentTab = index; for (int i = 0; i < tabBar.getChildCount(); i++) { View child = tabBar.getChildAt(i); boolean on = (i == index); child.setBackgroundColor(0x00000000); if (child instanceof TextView) { ((TextView) child).setTextColor(on ? 0xFFFFFFFF : C_FG); child.animate().alpha(on ? 1f : 0.72f).setDuration(200).start(); } } moveTabIndicator(tabBar.getChildAt(index)); tabContent.removeAllViews(); cellQueue.clear(); for (Act item : tabDefs[index].acts) { addRow(tabContent, action(item.label, item.task), null); } flushGrid(tabContent); // 这个分页负责的功能分组 if (tabDefs[index].groups.length > 0) { renderGroups(tabContent, tabDefs[index].groups); } // 切页时内容淡入 + 轻微上移,做出"流动"的感觉 animateIn(tabContent, 10f, 220); } /** 把一个分页负责的功能分组渲染成按钮(每组带一个小标题)。 */ private void renderGroups(final LinearLayout container, final String[] groups) { if (featureCache == null || featureCache.isEmpty()) { // 还没拉到功能列表:先显示提示,拉到之后重画当前页 TextView hint = new TextView(this); hint.setText("正在获取电脑端功能…"); hint.setTextColor(C_DIM); hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11); hint.setPadding(dp(4), dp(6), dp(4), dp(6)); container.addView(hint); if (featureGroups == null) { io.execute(new Runnable() { @Override public void run() { try { Map reply = client.call(Json.obj("cmd", "feature_list"), 25000); final List> items = mapList(reply, "features"); ui.post(new Runnable() { @Override public void run() { featureCache = items; featureGroups = new ArrayList(); for (Map f : items) { String g = Json.str(f, "group", "其他"); if (!featureGroups.contains(g)) { featureGroups.add(g); } } selectTab(currentTab); } }); } catch (final IOException e) { ui.post(new Runnable() { @Override public void run() { appendLog("获取功能列表失败:" + e.getMessage()); toast("没连上电脑,功能列表拉不到"); } }); } } }); } return; } for (final String group : groups) { final List> items = new ArrayList>(); for (Map f : featureCache) { if (group.equals(Json.str(f, "group", ""))) { items.add(f); } } if (items.isEmpty()) { continue; } TextView header = new TextView(this); header.setText(group); header.setTextColor(C_ACCENT); header.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); header.setPadding(dp(4), dp(10), dp(4), dp(2)); container.addView(header); LinearLayout grid = new LinearLayout(this); grid.setOrientation(LinearLayout.VERTICAL); container.addView(grid); cellQueue.clear(); for (final Map item : items) { addRow(grid, action(Json.str(item, "label", "?"), new Runnable() { @Override public void run() { dispatchFeature(item); } }), null); } flushGrid(grid); } } /** 指示器滑到目标标签;宽度和位置一起动。 */ private void moveTabIndicator(final View target) { if (tabIndicator == null || target == null) { return; } if (target.getWidth() == 0) { // 布局还没完成,等一帧再试 —— 但必须有限次。 // 面板被 GONE 掉时父容器不再 layout,宽度永远是 0, // 无条件重投会让主线程空转,100% CPU 发烫耗电。 if (indicatorRetries >= 5) { indicatorRetries = 0; return; } indicatorRetries++; tabIndicator.post(new Runnable() { @Override public void run() { moveTabIndicator(target); } }); return; } indicatorRetries = 0; final int startX = (int) tabIndicator.getTranslationX(); final int startW = tabIndicator.getLayoutParams().width; final int endX = tabBar.getLeft() + target.getLeft(); final int endW = target.getWidth(); ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f); animator.setDuration(300); animator.setInterpolator(new android.view.animation.DecelerateInterpolator()); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float fraction = (Float) animation.getAnimatedValue(); ViewGroup.LayoutParams params = tabIndicator.getLayoutParams(); params.width = (int) (startW + (endW - startW) * fraction); tabIndicator.setLayoutParams(params); tabIndicator.setTranslationX(startX + (endX - startX) * fraction); } }); animator.start(); } private void buildFeatureTab() { tabContent.removeAllViews(); cellQueue.clear(); if (featureGroups == null) { TextView hint = new TextView(this); hint.setText("正在获取电脑端功能列表…"); hint.setTextColor(C_DIM); hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); hint.setPadding(dp(4), dp(8), dp(4), dp(8)); tabContent.addView(hint); io.execute(new Runnable() { @Override public void run() { try { Map reply = client.call(Json.obj("cmd", "feature_list"), 20000); final List> items = mapList(reply, "features"); final List groups = new ArrayList(); for (Map item : items) { String group = Json.str(item, "group", "其他"); if (!groups.contains(group)) { groups.add(group); } } featureCache = items; ui.post(new Runnable() { @Override public void run() { featureGroups = groups; selectTab(currentTab); } }); } catch (final IOException e) { ui.post(new Runnable() { @Override public void run() { appendLog("获取功能列表失败:" + e.getMessage()); toast("没能连上电脑"); } }); } } }); return; } if (featureGroups.isEmpty()) { TextView hint = new TextView(this); hint.setText("电脑端没有声明任何功能"); hint.setTextColor(C_DIM); hint.setPadding(dp(4), dp(8), dp(4), dp(8)); tabContent.addView(hint); return; } for (final String group : featureGroups) { addRow(tabContent, action(group, new Runnable() { @Override public void run() { pickFeatureGroup(group); } }), null); } flushGrid(tabContent); } // ================================================================== // 新功能 // ================================================================== private void media(String what) { sendCommand(Json.obj("cmd", "media", "action", what), "媒体"); } private void confirmMonitor() { new AlertDialog.Builder(this) .setTitle("关闭显示器") .setMessage("要关掉电脑显示器吗?(动一下鼠标或按键就能唤醒)") .setPositiveButton("关掉", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface d, int w) { sendCommand(Json.obj("cmd", "monitor", "on", false), "关显示器"); } }) .setNegativeButton("取消", null) .show(); } private void confirmWallpaper() { new AlertDialog.Builder(this) .setTitle("设为壁纸") .setMessage("把电脑上最近收到的那张图片设为桌面壁纸?") .setPositiveButton("设置", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface d, int w) { sendCommand(Json.obj("cmd", "set_wallpaper"), "设置壁纸"); } }) .setNegativeButton("取消", null) .show(); } private void pickShutdownTimer() { final int[] minutes = {15, 30, 60, 120}; final String[] labels = {"15 分钟后", "30 分钟后", "1 小时后", "2 小时后"}; showChooser("定时关机", new ArrayList(java.util.Arrays.asList(labels)), new Chooser() { @Override public void onPick(int index) { sendCommand(Json.obj("cmd", "shutdown_timer", "minutes", minutes[index]), "定时关机"); } }); } private void cancelShutdown() { sendCommand(Json.obj("cmd", "shutdown_timer", "cancel", true), "取消定时关机"); } private void promptOpenUrl() { promptInput("在电脑上打开网址", "如 baidu.com", "https://", new InputHandler() { @Override public void onInput(String text) { if (!text.isEmpty()) { sendCommand(Json.obj("cmd", "open_url", "url", text), "打开网址"); } } }); } private void pickQuickApp() { final String[][] apps = { {"任务管理器", "taskmgr"}, {"资源管理器", "explorer"}, {"命令提示符", "cmd"}, {"计算器", "calc"}, {"记事本", "notepad"}, {"画图", "mspaint"}, {"控制面板", "control"}, {"设备管理器", "devmgmt"}, }; List labels = new ArrayList(); for (String[] item : apps) { labels.add(item[0]); } showChooser("在电脑上打开", labels, new Chooser() { @Override public void onPick(int index) { sendCommand(Json.obj("cmd", "quick", "app", apps[index][1]), "打开" + apps[index][0]); } }); } private void pickFolder() { final String[][] folders = { {"下载", "downloads"}, {"文档", "documents"}, {"桌面", "desktop"}, {"图片", "pictures"}, {"音乐", "music"}, {"接收目录", "received"}, {"程序目录", "app"}, }; List labels = new ArrayList(); for (String[] item : folders) { labels.add(item[0]); } showChooser("打开电脑文件夹", labels, new Chooser() { @Override public void onPick(int index) { sendCommand(Json.obj("cmd", "open_folder", "folder", folders[index][1]), "打开" + folders[index][0]); } }); } private void pickProcess() { appendLog("正在获取电脑进程…"); io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(Json.obj("cmd", "proc_list"), 20000); final List names = stringList(reply, "names"); final List pids = stringList(reply, "pids"); ui.post(new Runnable() { @Override public void run() { if (names.isEmpty()) { toast("没读到进程,或没连上"); return; } showChooser("占内存最多的进程", names, new Chooser() { @Override public void onPick(int index) { // pids 可能比 names 短(服务端漏发或被截断), // 不校验直接 get 会崩在 UI 线程 if (index < 0 || index >= names.size()) { return; } String pid = index < pids.size() ? pids.get(index) : ""; confirmKill(names.get(index), pid); } }); } }); } catch (final IOException e) { postError("获取进程", e); } } }); } private void confirmKill(String label, String pid) { final int target; try { target = Integer.parseInt(pid); } catch (NumberFormatException e) { toast("进程号不对"); return; } new AlertDialog.Builder(this) .setTitle("结束进程") .setMessage("确定要结束这个进程吗?\n\n" + label) .setPositiveButton("结束", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface d, int w) { sendCommand(Json.obj("cmd", "proc_kill", "pid", target), "结束进程"); } }) .setNegativeButton("取消", null) .show(); } private void showPerf() { fetchAndShow("性能", Json.obj("cmd", "perf"), "note"); } private void showDisk() { fetchAndShow("磁盘占用", Json.obj("cmd", "disk"), "note"); } private void showBattery() { fetchAndShow("电脑电量", Json.obj("cmd", "battery"), "note"); } /** 拉一条命令,把 note 用对话框显示出来。 */ private void fetchAndShow(final String title, final Map request, final String field) { appendLog("正在获取" + title + "…"); io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(request, 15000); final boolean ok = Json.bool(reply, "ok", false); final String note = ok ? Json.str(reply, field, "(无内容)") : Json.str(reply, "error", "读取失败"); ui.post(new Runnable() { @Override public void run() { appendLog(title + ":" + note); new AlertDialog.Builder(MainActivity.this) .setTitle(title) .setMessage(note) .setPositiveButton("知道了", null) .show(); } }); } catch (final IOException e) { postError(title, e); } } }); } private void pickClipHistory() { appendLog("正在获取剪贴板历史…"); io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(Json.obj("cmd", "clip_history"), 12000); final List texts = stringList(reply, "texts"); final List times = stringList(reply, "times"); ui.post(new Runnable() { @Override public void run() { if (texts.isEmpty()) { toast("电脑剪贴板还没有历史"); return; } List labels = new ArrayList(); for (int i = 0; i < texts.size(); i++) { String preview = texts.get(i).replace("\n", " "); if (preview.length() > 22) { preview = preview.substring(0, 22) + "…"; } String when = i < times.size() ? times.get(i) + " " : ""; labels.add(when + preview); } showChooser("剪贴板历史(点一条取到手表)", labels, new Chooser() { @Override public void onPick(int index) { copyToWatchClipboard(texts.get(index)); toast("已复制到手表剪贴板"); appendLog("取回一条剪贴板历史"); } }); } }); } catch (final IOException e) { postError("剪贴板历史", e); } } }); } // ================================================================== // 电脑功能(由电脑端声明,这里只负责拉列表和调用) // ================================================================== private List> featureCache = null; private List featureGroups = null; private View tabIndicator; private int indicatorRetries = 0; /** 按分组列出电脑端的功能。 */ private void pickFeatureGroup(final String group) { appendLog("正在获取「" + group + "」功能…"); io.execute(new Runnable() { @Override public void run() { try { if (featureCache == null) { Map reply = client.call(Json.obj("cmd", "feature_list"), 20000); featureCache = mapList(reply, "features"); } final List> items = new ArrayList>(); List labels = new ArrayList(); for (Map item : featureCache) { if (!group.equals(Json.str(item, "group", ""))) { continue; } items.add(item); labels.add(Json.str(item, "label", "?")); } final List finalLabels = labels; ui.post(new Runnable() { @Override public void run() { if (finalLabels.isEmpty()) { toast("这一组暂时没有功能"); return; } showChooser("电脑功能 · " + group, finalLabels, new Chooser() { @Override public void onPick(int index) { dispatchFeature(items.get(index)); } }); } }); } catch (final IOException e) { featureCache = null; postError("获取功能列表", e); } } }); } /** 按功能类型决定怎么执行。 */ private void dispatchFeature(final Map feature) { final String name = Json.str(feature, "name", ""); final String label = Json.str(feature, "label", name); final String kind = Json.str(feature, "kind", "action"); if (name.isEmpty()) { toast("功能名不对"); return; } if ("toggle".equals(kind)) { final boolean on = Json.bool(feature, "on", false); final boolean next = !on; runFeature(name, Json.obj("on", next), (on ? "关闭 " : "开启 ") + label); // 关键:本地缓存必须跟着翻转。 // 不翻转的话,下次点还是拿旧状态取反,永远往同一个方向走 —— // 表现就是"能隐藏但恢复不了"、"显示已关闭但打不开"。 feature.put("on", Boolean.valueOf(next)); return; } if ("value".equals(kind)) { int min = Json.integer(feature, "min", 0); int max = Json.integer(feature, "max", 100); final List labels = new ArrayList(); final List values = new ArrayList(); for (int step = 1; step <= 5; step++) { int value = min + (max - min) * step / 5; values.add(value); labels.add(String.valueOf(value)); } showChooser(label, labels, new Chooser() { @Override public void onPick(int index) { runFeature(name, Json.obj("value", values.get(index)), label + " = " + values.get(index)); } }); return; } // 文件类:不在电脑上"执行",而是让手表把文件拉过来 if ("file".equals(kind)) { promptInput(label, Json.str(feature, "hint", "文件路径"), "", new InputHandler() { @Override public void onInput(String path) { if (!path.isEmpty()) { pullFile(path); } } }); return; } if ("input".equals(kind)) { String hint = Json.str(feature, "hint", "输入"); final String key = Json.str(feature, "key", "text"); String hint2 = Json.str(feature, "hint2", ""); final String key2 = Json.str(feature, "key2", ""); if (!key2.isEmpty() && !hint2.isEmpty()) { promptTwo(label, hint, hint2, new TwoHandler() { @Override public void onInput(String first, String second) { Map params = Json.obj(key, first); params.put(key2, second); runFeature(name, params, label); } }); return; } promptInput(label, hint, "", new InputHandler() { @Override public void onInput(String text) { if (!text.isEmpty()) { runFeature(name, Json.obj(key, text), label); } } }); return; } // action:删除类的先确认 if (name.startsWith("delete_") || name.equals("empty_recycle") || name.equals("clear_clip") || name.equals("restart_explorer")) { new AlertDialog.Builder(this) .setTitle(label) .setMessage("确定要执行「" + label + "」吗?") .setPositiveButton("执行", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface d, int w) { runFeature(name, Json.obj(), label); } }) .setNegativeButton("取消", null) .show(); return; } runFeature(name, Json.obj(), label); } private void runFeature(final String name, final Map params, final String label) { appendLog("执行:" + label); io.execute(new Runnable() { @Override public void run() { try { Map request = Json.obj("cmd", "feature_run", "name", name); request.put("params", params); final Map reply = client.call(request, 30000); final boolean ok = Json.bool(reply, "ok", false); final String note = ok ? Json.str(reply, "note", "已执行") : Json.str(reply, "error", "执行失败"); ui.post(new Runnable() { @Override public void run() { appendLog(label + ":" + firstLine(note)); // 查看类的结果比较长,用对话框显示 if (note.length() > 40 || note.contains("\n")) { new AlertDialog.Builder(MainActivity.this) .setTitle(label) .setMessage(note) .setPositiveButton("知道了", null) .show(); } else { toast(note); } } }); } catch (final IOException e) { postError(label, e); } } }); } private static String firstLine(String text) { int cut = text.indexOf('\n'); return cut > 0 ? text.substring(0, cut) : text; } /** 两个输入框的对话框。 */ private interface TwoHandler { void onInput(String first, String second); } private void promptTwo(String title, String hint1, String hint2, final TwoHandler handler) { LinearLayout box = new LinearLayout(this); box.setOrientation(LinearLayout.VERTICAL); box.setPadding(dp(14), dp(8), dp(14), 0); final EditText first = new EditText(this); first.setHint(hint1); first.setSingleLine(true); box.addView(first); final EditText second = new EditText(this); second.setHint(hint2); second.setSingleLine(true); box.addView(second); new AlertDialog.Builder(this) .setTitle(title) .setView(box) .setPositiveButton("确定", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface d, int w) { handler.onInput(first.getText().toString().trim(), second.getText().toString().trim()); } }) .setNegativeButton("取消", null) .show(); } /** 把回复里的对象数组取出来。 */ @SuppressWarnings("unchecked") private static List> mapList(Map reply, String key) { List> out = new ArrayList>(); try { Object raw = reply == null ? null : reply.get(key); if (raw instanceof List) { for (Object item : (List) raw) { if (item instanceof Map) { out.add((Map) item); } } } } catch (Exception ignored) { // 结构不对就返回空 } return out; } private Button bigButton(String text, int color, float sizeSp) { Button button = new Button(this); button.setText(text); button.setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp); button.setTextColor(readableOn(color)); button.setBackground(glassButton(color, 16)); button.setAllCaps(false); attachPress(button); return button; } // ================================================================== // 面板切换 // ================================================================== // ================================================================== // 局域网连接(不经过中转) // ================================================================== private void buildLanPanel() { lanPanel = new FrameLayout(this); lanPanel.setBackgroundColor(C_BG); ScrollView scroll = new ScrollView(this); scroll.setBackgroundColor(C_BG); LinearLayout box = new LinearLayout(this); box.setOrientation(LinearLayout.VERTICAL); box.setPadding(dp(10), dp(12), dp(10), dp(12)); scroll.addView(box, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); lanPanel.addView(scroll, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); // ---- 标题 + 返回 ---- LinearLayout top = new LinearLayout(this); top.setOrientation(LinearLayout.HORIZONTAL); top.setGravity(Gravity.CENTER_VERTICAL); TextView title = new TextView(this); title.setText("局域网连接"); title.setTextColor(C_FG); title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 17); LinearLayout.LayoutParams tParams = new LinearLayout.LayoutParams( 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); title.setLayoutParams(tParams); top.addView(title); top.addView(smallButton("返回", C_CARD, new Runnable() { @Override public void run() { hideLanPanel(); } })); box.addView(top); TextView hint = new TextView(this); hint.setText("手表和电脑连同一个 WiFi 时用这个,速度更快、不占流量。\n" + "电脑上要开着 ykxmz。"); hint.setTextColor(C_DIM); hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); hint.setPadding(0, dp(6), 0, dp(10)); box.addView(hint); // ---- 自动搜索 ---- Button search = bigButton("自动搜索电脑", C_ACCENT, 14); LinearLayout.LayoutParams sParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(50)); sParams.setMargins(0, 0, 0, dp(8)); search.setLayoutParams(sParams); search.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { startLanSearch(); } }); box.addView(search); lanStatus = new TextView(this); lanStatus.setTextColor(C_DIM); lanStatus.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); lanStatus.setPadding(0, 0, 0, dp(6)); box.addView(lanStatus); lanResultBox = new LinearLayout(this); lanResultBox.setOrientation(LinearLayout.VERTICAL); box.addView(lanResultBox); // ---- 手动填地址 ---- TextView manualTip = new TextView(this); manualTip.setText("搜不到就自己填(电脑上 ykxmz 会显示 IP)"); manualTip.setTextColor(C_DIM); manualTip.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); manualTip.setPadding(0, dp(10), 0, dp(4)); box.addView(manualTip); lanIpInput = makeInput("电脑 IP,如 192.168.10.105", InputType.TYPE_CLASS_TEXT); box.addView(labeled("电脑 IP", lanIpInput)); lanPortInput = makeInput("端口(默认 8765)", InputType.TYPE_CLASS_NUMBER); box.addView(labeled("端口", lanPortInput)); lanCodeInput = makeInput("6 位配对码", InputType.TYPE_CLASS_NUMBER); box.addView(labeled("配对码", lanCodeInput)); Button connect = bigButton("连上这台电脑", C_OK, 15); LinearLayout.LayoutParams cParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(52)); cParams.setMargins(0, dp(10), 0, 0); connect.setLayoutParams(cParams); connect.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { connectLanTyped(); } }); box.addView(connect); } private void showLanPanel() { connectPanel.setVisibility(View.GONE); controlPanel.setVisibility(View.GONE); if (mirrorPanel != null) { mirrorPanel.setVisibility(View.GONE); } lanPanel.setVisibility(View.VISIBLE); getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); // 进来先自动搜一次,省得用户还要自己点 String savedCode = savedPairingCode(); if (lanCodeInput != null && savedCode != null && !savedCode.isEmpty() && lanCodeInput.getText().length() == 0) { lanCodeInput.setText(savedCode); } startLanSearch(); } private void hideLanPanel() { lanPanel.setVisibility(View.GONE); showConnectPanel(); } /** 广播搜一圈,找到的电脑列出来。 */ private void startLanSearch() { if (lanSearching) { return; } lanSearching = true; if (lanResultBox != null) { lanResultBox.removeAllViews(); } setLanStatus("正在搜索…(最多 6 秒)", C_DIM); appendLog("局域网搜索开始"); io.execute(new Runnable() { @Override public void run() { java.util.List found; try { found = Discovery.search(MainActivity.this, 4000); } catch (Throwable error) { found = new java.util.ArrayList(); appendLog("搜索失败:" + error); } final java.util.List result = found; runOnUiThread(new Runnable() { @Override public void run() { lanSearching = false; if (lanResultBox == null) { return; } lanResultBox.removeAllViews(); if (result.isEmpty()) { setLanStatus("没搜到电脑。确认:① 电脑开着 ykxmz ② " + "同一个 WiFi ③ 路由器没开 AP 隔离。" + "\n也可以直接填下面的 IP。", C_WARN); appendLog("局域网没搜到电脑"); return; } setLanStatus("搜到 " + result.size() + " 台,点一下就连", C_OK); appendLog("搜到 " + result.size() + " 台电脑"); for (final DiscoveryCore.Found item : result) { Button b = bigButton( item.name + " · " + item.host, C_CARD, 14); LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(50)); lp.setMargins(0, 0, 0, dp(6)); b.setLayoutParams(lp); b.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { if (lanIpInput != null) { lanIpInput.setText(item.host); } if (lanPortInput != null) { lanPortInput.setText( String.valueOf(item.port)); } connectLanTyped(); } }); lanResultBox.addView(b); } } }); } }); } private void setLanStatus(String text, int color) { if (lanStatus != null) { lanStatus.setText(text); lanStatus.setTextColor(color); } } /** 用手填/点选的地址直连,不走中转。 */ private void connectLanTyped() { String host = lanIpInput == null ? "" : lanIpInput.getText().toString().trim(); String portText = lanPortInput == null ? "" : lanPortInput.getText().toString().trim(); String code = lanCodeInput == null ? "" : lanCodeInput.getText().toString().trim(); if (host.isEmpty()) { setLanStatus("先填电脑 IP,或者上面搜一下", C_WARN); toast("还没填电脑 IP"); return; } int port = RemoteClient.DEFAULT_PORT; if (!portText.isEmpty()) { try { port = Integer.parseInt(portText); } catch (NumberFormatException error) { setLanStatus("端口要是数字", C_WARN); return; } } if (code.isEmpty()) { setLanStatus("要填 6 位配对码", C_WARN); toast("还没填配对码"); return; } rememberPairingCode(code); lastHost = host; lastPort = port; lastCode = code; lanDirect = true; // 断了自动切回中转 appendLog("局域网直连 " + host + ":" + port); // preferLan = true:用户就是冲着"局域网直连"来的, // 必须真的直连,不能因为有中转地址就偷偷走中转。 connect(host, port, code, null, DeviceStore.deviceKey(host, port, store.deviceId()), true); } // ================================================================== // 服务器延迟检测 // ================================================================== private void checkServer() { final String relay = relayUrl(); if (relay.isEmpty()) { if (serverStatus != null) { serverStatus.setText("服务器:还没设中转地址"); serverStatus.setTextColor(C_WARN); } return; } if (serverStatus != null) { serverStatus.setText("服务器:正在测…"); serverStatus.setTextColor(C_DIM); } appendLog("开始检查服务器 " + relay); io.execute(new Runnable() { @Override public void run() { final long started = System.currentTimeMillis(); String text; int color = C_DIM; try { java.net.URL url = new java.net.URL(relay + "/v1/health"); java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection(); conn.setRequestProperty("User-Agent", "LanRemote/1.4.0"); conn.setConnectTimeout(8000); conn.setReadTimeout(8000); conn.setRequestMethod("GET"); int code = conn.getResponseCode(); long ms = System.currentTimeMillis() - started; if (code == 200) { text = "服务器:延迟 " + ms + " 毫秒"; color = ms < 300 ? C_OK : (ms < 900 ? C_WARN : C_DANGER); } else { text = "服务器:HTTP " + code; color = C_DANGER; } try { conn.disconnect(); } catch (Throwable ignored) { // 关不掉也无所谓 } } catch (Throwable error) { text = "服务器:连不上(" + shortReason(error) + ")"; color = C_DANGER; } final String shown = text; final int tint = color; runOnUiThread(new Runnable() { @Override public void run() { if (serverStatus != null) { serverStatus.setText(shown); serverStatus.setTextColor(tint); } appendLog(shown); } }); } }); } /** 异常名压短,免得界面上一长串。 */ private static String shortReason(Throwable error) { String name = error.getClass().getSimpleName(); if (name.isEmpty()) { name = "错误"; } String message = String.valueOf(error.getMessage()); if (message.length() > 40) { message = message.substring(0, 40); } return message.isEmpty() || "null".equals(message) ? name : (name + " " + message); } private void showConnectPanel() { connectPanel.setVisibility(View.VISIBLE); controlPanel.setVisibility(View.GONE); if (mirrorPanel != null) { mirrorPanel.setVisibility(View.GONE); } getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); } private void showControlPanel() { connectPanel.setVisibility(View.GONE); controlPanel.setVisibility(View.VISIBLE); if (mirrorPanel != null) { mirrorPanel.setVisibility(View.GONE); } getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING); } private void setStatus(String text, int color) { if (statusView != null) { statusView.setText(text); statusView.setTextColor(color); } } // ================================================================== // 连接 // ================================================================== /** * 让用户输 6 位配对码,直接连上那台电脑。 * * 现在只有这一种连接方式了 —— 统一经中转。 * 不用管电脑 IP、端口、在不在同一个局域网。 */ private void promptPairingCode() { final EditText input = new EditText(this); input.setInputType(InputType.TYPE_CLASS_NUMBER); input.setHint("6 位数字"); input.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20); input.setGravity(Gravity.CENTER); input.setPadding(dp(12), dp(14), dp(12), dp(14)); String last = savedPairingCode(); if (last != null && !last.isEmpty()) { input.setText(last); input.setSelection(input.getText().length()); } new AlertDialog.Builder(this) .setTitle("输入配对码") .setMessage("填电脑上显示的 6 位配对码\n" + "(中转地址:" + relayUrl() + ")") .setView(input) .setPositiveButton("连接", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { connectByCode(input.getText().toString().trim()); } }) .setNegativeButton("取消", null) .show(); } /** 拿着配对码经中转连过去。 */ private void connectByCode(String code) { if (code == null || code.length() != 6 || !code.matches("\\d{6}")) { setStatus("配对码是 6 位数字", C_WARN); toast("配对码要 6 位数字"); return; } String relay = relayUrl(); if (relay.isEmpty()) { setStatus("还没设置中转地址", C_WARN); toast("请先在「中转设置」里填服务器地址"); promptRelayUrl(); return; } appendLog("用配对码连接:" + code); // host 传中转地址:经中转时真正连的是中转,host 只用于区分已配对的设备 connect(relay, 0, code, null, "code:" + code); } private void quickConnect(String host, int port, String token, String key) { // 不再往输入框里写东西(那两个框已经删了)—— // 直接连。host/port 对中转路径没有意义, // 但 key 仍然用来区分"哪台已配对的电脑"。 // // 先判断这个保存下来的地址还有没有意义: // 电脑换过网络的话(比如从 192.168.2.x 换到 192.168.10.x), // 直连必然失败。那就别浪费那 6 秒,直接走中转 —— // 中转连上后会报回电脑的最新局域网地址,记录自动刷新。 boolean sameNet = host.isEmpty() || hostInMySubnet(host); if (!sameNet) { appendLog("记录的地址 " + host + " 和手表不在同一网段" + "(电脑可能换了网络),直接走中转查最新地址"); toast("电脑地址已过期,正在通过中转查询最新地址…"); } // preferLan = true 时先按记录里的地址直连,失败自动退回中转。 connect(host, port, null, token, key, sameNet); } /** * host 是不是和手表在同一个 /24 网段。 * *

读不到手表自己的网卡时返回 true(保守:宁可按原地址试一次, * 也不要因为拿不到权限就误判成"过期",那会让本可以直连的场景 * 白走一趟中转)。 */ private boolean hostInMySubnet(String host) { if (host == null || host.isEmpty()) { return true; } java.util.List mine = localIpv4List(); if (mine.isEmpty()) { return true; } for (String myIp : mine) { if (sameSubnet(host, myIp)) { return true; } } return false; } /** 手表自己在局域网里的 IPv4 地址(可能多张网卡)。拿不到就空。 */ private java.util.List localIpv4List() { java.util.List found = new java.util.ArrayList(); try { java.util.Enumeration interfaces = java.net.NetworkInterface.getNetworkInterfaces(); while (interfaces != null && interfaces.hasMoreElements()) { java.net.NetworkInterface nic = interfaces.nextElement(); if (nic == null || !nic.isUp() || nic.isLoopback()) { continue; } java.util.Enumeration addrs = nic.getInetAddresses(); while (addrs != null && addrs.hasMoreElements()) { java.net.InetAddress addr = addrs.nextElement(); if (addr instanceof java.net.Inet4Address && !addr.isLoopbackAddress()) { String ip = addr.getHostAddress(); if (ip != null && !ip.startsWith("169.254.") && !found.contains(ip)) { found.add(ip); } } } } } catch (Exception ignored) { // 权限不足之类,拿不到就算了 —— 那就不切换,继续用中转 } return found; } /** 两个 IPv4 是不是同一个 /24 网段(家用网络基本都够用)。 */ private boolean sameSubnet(String a, String b) { if (a == null || b == null) { return false; } String[] left = a.split("\\."); String[] right = b.split("\\."); if (left.length != 4 || right.length != 4) { return false; } return left[0].equals(right[0]) && left[1].equals(right[1]) && left[2].equals(right[2]); } /** * 从电脑报上来的局域网地址里,挑一个和手表同网段的。 * * 挑不到就返回 null —— 表示不在同一个网络,继续走中转。 */ /** 从电脑报来的 IP 里挑一个能用的(不限网段)。 */ private String pickAnyLanIp(java.util.List deviceIps) { if (deviceIps == null) { return null; } for (String ip : deviceIps) { if (ip != null && !ip.trim().isEmpty()) { return ip.trim(); } } return null; } private String pickSameSubnet(java.util.List deviceIps) { if (deviceIps == null || deviceIps.isEmpty()) { return null; } java.util.List mine = localIpv4List(); if (mine.isEmpty()) { return null; } for (String deviceIp : deviceIps) { for (String myIp : mine) { if (sameSubnet(deviceIp, myIp)) { return deviceIp; } } } return null; } // ---- 断线自动切换用的状态 ---- /** 当前是不是走局域网直连(断了要切回中转)。 */ private volatile boolean lanDirect; /** 刚才连的地址/配对码/令牌,切回中转时要用。 */ private volatile String lastHost = ""; private volatile int lastPort; private volatile String lastCode = ""; private volatile String lastToken = ""; /** 正在自动切换中,避免重入。 */ private volatile boolean autoSwitching; /** 自动重试次数,防止无限刷屏。 */ private int autoRetry; private void connect(final String host, final int port, final String code, final String token, final String key) { connect(host, port, code, token, key, false); } /** * @param preferLan 明确要求"先走局域网"。 * *

用户点「局域网连接」填了 IP,或者点首页已配对电脑时, * 应该先按他给的地址直连。原来的实现只看中转地址填没填 —— * 填了就一律走中转,**完全无视用户填的局域网 IP**, * 而中转超时 40 秒,用户点完只看到「正在连接…」,以为没反应。 * *

设 true 时:先直连(局域网该是毫秒级,超时给 6 秒), * 不成再退回中转,保证最终能连上。 */ private void connect(final String host, final int port, final String code, final String token, final String key, final boolean preferLan) { if (busy) { return; } busy = true; // 记下来:断线时要用这些信息切回中转 / 重连 lastHost = host; lastPort = port; if (code != null && !code.isEmpty()) { lastCode = code; // 存起来:以后用令牌直连时(code 是 null), // 局域网断线要切中转,还得靠这个码才能接上。 rememberPairingCode(code); } else if (lastCode == null || lastCode.isEmpty()) { lastCode = savedPairingCode(); } if (token != null) { lastToken = token; } setStatus("正在连接 " + host + ":" + port + " …", C_WARN); appendLog("连接 " + host + ":" + port); io.execute(new Runnable() { @Override public void run() { String error = null; String newToken = null; String serverName = null; boolean ok = false; // 这次连上的是局域网还是中转(决定断线后怎么救) boolean usedLan = !relayUrl().isEmpty() ? false : true; try { final String relay = relayUrl(); Map reply; if (preferLan) { // ---- 用户明确要走局域网:先直连,失败再退中转 ---- // 局域网直连本该是毫秒级,所以超时给短一点。 // 原来这里会走中转(40 秒超时),用户以为卡死了。 appendLog("局域网直连 " + host + ":" + port + " …"); reply = client.connect(host, port, code, token, MainActivity.this, 6000); ok = Json.bool(reply, "ok", false); if (ok) { usedLan = true; } else if (!relay.isEmpty()) { String lanWhy = Json.str(reply, "error", "局域网直连失败"); appendLog("局域网直连不成(" + lanWhy + "),改走中转…"); ui.post(new Runnable() { @Override public void run() { setStatus("局域网连不上,改用中转…", C_WARN); } }); reply = client.connectViaRelay(relay, code, token, MainActivity.this, 40000); usedLan = false; } } else if (!relay.isEmpty()) { // 跨网络:电脑和手表都在 NAT 后面,直连不可能, // 两边各自连出去、由中转按配对码对接。 // 走公网要几个 HTTP 往返,超时给宽一点。 appendLog("经中转连接:" + relay); reply = client.connectViaRelay(relay, code, token, MainActivity.this, 40000); } else { reply = client.connect(host, port, code, token, MainActivity.this, 9000); } ok = Json.bool(reply, "ok", false); if (ok) { newToken = Json.str(reply, "token", token == null ? "" : token); serverName = Json.str(reply, "serverName", host); // ★ 经中转连上之后,看看电脑是不是和我们在同一个网络。 // 是的话就切成局域网直连 —— 快得多(几十毫秒 vs 几百毫秒), // 而且不消耗流量、不怕隧道抖。 if (!relay.isEmpty()) { String directIp = pickSameSubnet(client.relayLanIps()); int directPort = client.relayLanPort(); if (directIp != null && directPort > 0) { appendLog("电脑和手表在同一网络(" + directIp + "),切换成局域网直连"); client.close(); Map direct = client.connect( directIp, directPort, code, newToken, MainActivity.this, 8000); if (Json.bool(direct, "ok", false)) { reply = direct; newToken = Json.str(direct, "token", newToken == null ? "" : newToken); serverName = Json.str(direct, "serverName", directIp); appendLog("已切换为局域网直连:" + directIp + ":" + directPort); usedLan = true; } else { // 直连没成(可能不在同一网段,只是网段巧合)。 // 退回中转,不要让用户连不上。 appendLog("直连失败,退回中转:" + Json.str(direct, "error", "未知原因")); usedLan = false; reply = client.connectViaRelay(relay, code, newToken, MainActivity.this, 40000); ok = Json.bool(reply, "ok", false); if (ok) { newToken = Json.str(reply, "token", newToken == null ? "" : newToken); serverName = Json.str(reply, "serverName", directIp); } else { error = Json.str(reply, "error", "退回中转也失败"); } } } else { // 不在同一网段 -> 继续走中转。 // 光说"不在同一网络"用户没法自查,所以把 // 双方地址都打出来:一眼就能看出是网段不同、 // 还是手表连错了 WiFi。 java.util.List theirs = client.relayLanIps(); java.util.List mine = localIpv4List(); appendLog("电脑在 " + (theirs == null || theirs.isEmpty() ? "(没报地址)" : theirs.get(0)) + ",手表在 " + (mine.isEmpty() ? "(读不到网卡)" : mine.get(0)) + " —— 不在同一网段,走中转"); appendLog("想让手表直连:确认两边连的是同一个" + " WiFi(注意别连成 5G 频段的另一个名字)"); } } } else { error = Json.str(reply, "error", "连接被拒绝"); } } catch (final Exception e) { // 放宽到 Exception:中转那边的异常可能是包过的, // 只 catch IOException 会漏掉(甚至闪退)。 error = describeNetworkError(e, host); } final boolean success = ok; final String finalError = error; final String finalToken = newToken; final String finalName = serverName; final boolean finalLanDirect = usedLan && ok; // 这次是不是走中转连上的(决定要不要用服务器报的地址刷新记录) final boolean finalFromRelay = !relayUrl().isEmpty() && ok; ui.post(new Runnable() { @Override public void run() { busy = false; if (success) { store.setLastEndpoint(host, port); if (finalToken != null && !finalToken.isEmpty()) { // 经中转连上时,服务器会报回电脑当前的局域网地址。 // 用它更新已配对记录 —— 不然列表里一直显示旧 IP // (用户截图里就是 192.168.2.64,早就变了), // 点"直连"必然失败。 String savedHost = host; int savedPort = port; if (finalFromRelay) { String freshIp = pickAnyLanIp( client.relayLanIps()); int freshPort = client.relayLanPort(); if (freshIp != null && freshPort > 0) { savedHost = freshIp; savedPort = freshPort; appendLog("已更新电脑地址为 " + freshIp + ":" + freshPort); } } store.saveDevice(key, finalName == null ? host : finalName, savedHost, savedPort, finalToken); refreshDeviceList(); } // 记住这次是不是局域网直连 —— // 局域网断了要自动切回中转,靠的就是这个标记。 lanDirect = finalLanDirect; autoRetry = 0; if (serverLabel != null) { serverLabel.setText("已连接 " + (finalName == null ? host : finalName) + (finalLanDirect ? "(局域网)" : "(中转)")); } showControlPanel(); appendLog("已连接到 " + host); toast("连接成功"); } else { setStatus("连接失败:" + finalError, C_DANGER); appendLog("失败:" + finalError); } } }); } }); } /** * 把连接异常翻译成用户能看懂的话。 * *

手表上原本只会显示一长串英文类名(比如 * {@code SSLHandshakeException: Chain validation failed}), * 用户完全不知道该怎么办。这里按常见情况给出可操作的提示。 * *

证书类问题交给 {@link TlsCompat#explain} 判断 —— * 它会区分"证书链不认识""手表时间不对""被劫持"几种情况。 */ private String describeNetworkError(Throwable e, String host) { String raw = e == null ? "" : String.valueOf(e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); String lower = raw.toLowerCase(); // 证书 / 时间 / 解析 / 超时 等通用情况 String friendly = TlsCompat.explain(e); boolean generic = friendly != null && !friendly.equals(raw) && friendly.length() > 0; // TlsCompat 认不出来时会原样返回,那就继续往下走本场景的判断 boolean recognized = generic && ( friendly.contains("证书") || friendly.contains("时间") || friendly.contains("超时") || friendly.contains("解析") || friendly.contains("拒绝") || friendly.contains("联网") || friendly.contains("中断")); if (recognized) { return friendly; } if (lower.contains("etimedout") || lower.contains("timed out") || lower.contains("timeout")) { return "连接超时(网络慢或被防火墙挡了)"; } if (lower.contains("econnrefused") || lower.contains("refused")) { return "电脑拒绝连接 —— 确认电脑上 ykxmz 正在运行"; } if (lower.contains("enetunreach") || lower.contains("network is unreachable")) { return "网络不可达 —— 确认手表和电脑在同一个 WiFi"; } if (lower.contains("ehostunreach") || lower.contains("no route")) { return "找不到电脑 —— 检查 IP,或先用「局域网连接」搜一下"; } if (lower.contains("unable to resolve host") || lower.contains("unknownhost")) { return "域名解析不了 —— 检查手表能不能上网"; } // 中转那边的错误信息一般是中文的,直接透传 if (raw.length() > 120) { return raw.substring(0, 120); } return raw; } private void doDisconnect() { // 换连另一台电脑后,「功能」页不能还显示上一台的分组 featureCache = null; featureGroups = null; client.close(); showConnectPanel(); setStatus("已断开连接", C_DIM); appendLog("已断开"); } private boolean ensureConnected() { if (!client.isConnected()) { toast("尚未连接"); showConnectPanel(); return false; } return true; } // ================================================================== // 协议回调(读取线程) // ================================================================== @Override public void onEvent(final String event, final Map message) { if ("file".equals(event)) { handleIncomingFile(message); return; } if ("frame".equals(event)) { handleFrame(message); return; } if ("screen_stopped".equals(event)) { handleScreenStopped(message); return; } if ("clip".equals(event)) { final String text = Json.str(message, "text", ""); ui.post(new Runnable() { @Override public void run() { appendLog("电脑剪贴板:" + shorten(text, 40)); } }); } } @Override public void onDisconnected(final String reason) { // 换连另一台电脑后,「功能」页不能还显示上一台的分组 featureCache = null; featureGroups = null; // ★ 局域网断了 -> 无缝切回中转服务器。 // 用户从 WiFi 走出去、或者电脑换了网络,都会走到这里。 // 不打断操作,自动接上。 if (lanDirect && !autoSwitching) { String relay = relayUrl(); if (!relay.isEmpty()) { autoSwitching = true; appendLog("局域网断了(" + reason + "),正在切到中转服务器…"); ui.post(new Runnable() { @Override public void run() { setStatus("局域网断了,正在切到中转服务器…", C_WARN); } }); ui.postDelayed(new Runnable() { @Override public void run() { switchToRelay(); } }, 600); return; } appendLog("局域网断了,但没有配中转地址,只能断开"); } // 中转也断了:重试几次再放弃(这条线路本来就会抖) if (!lanDirect && autoRetry < 3 && !autoSwitching) { autoRetry++; appendLog("中转断了(" + reason + "),第 " + autoRetry + " 次自动重连…"); ui.post(new Runnable() { @Override public void run() { setStatus("中转断了,正在重连(第 " + autoRetry + " 次)…", C_WARN); } }); ui.postDelayed(new Runnable() { @Override public void run() { switchToRelay(); } }, 700 * autoRetry); return; } autoRetry = 0; ui.post(new Runnable() { @Override public void run() { mirrorStreaming = false; if (mirrorPanel != null && mirrorPanel.getVisibility() == View.VISIBLE) { mirrorPanel.setVisibility(View.GONE); } if (controlPanel.getVisibility() == View.VISIBLE) { showConnectPanel(); setStatus("连接已断开:" + reason, C_DANGER); } appendLog("断开:" + reason); lanDirect = false; } }); } /** * 经中转重连(局域网断了、或者中转抖了之后自动调用)。 * * 用后台线程做,不阻塞界面;成功了就继续用,失败才弹回连接页。 */ /** 记住用户输过的配对码(切中转时要用)。 */ private void rememberPairingCode(String code) { try { getSharedPreferences("lanremote", MODE_PRIVATE).edit() .putString("paired_code", code == null ? "" : code.trim()) .apply(); } catch (Exception ignored) { // 存不下也不致命,只是断线后可能切不了中转 } } /** 读回上次用的配对码。 */ private String savedPairingCode() { try { String saved = getSharedPreferences("lanremote", MODE_PRIVATE) .getString("paired_code", ""); return saved == null ? "" : saved.trim(); } catch (Exception ignored) { return ""; } } private void switchToRelay() { final String relay = relayUrl(); if (relay.isEmpty()) { autoSwitching = false; return; } final String code = lastCode; final String token = lastToken; io.execute(new Runnable() { @Override public void run() { String error = null; boolean ok = false; String newToken = token; String name = lastHost; try { Map reply = client.connectViaRelay( relay, code, token, MainActivity.this, 40000); ok = Json.bool(reply, "ok", false); if (ok) { newToken = Json.str(reply, "token", token == null ? "" : token); name = Json.str(reply, "serverName", lastHost); } else { error = Json.str(reply, "error", "中转连接被拒"); } } catch (final IOException exc) { error = describeNetworkError(exc, relay); } final boolean success = ok; final String finalError = error; final String finalToken = newToken; final String finalName = name; final String usedCode = code; ui.post(new Runnable() { @Override public void run() { autoSwitching = false; if (success) { lanDirect = false; lastToken = finalToken; if (finalToken != null && !finalToken.isEmpty()) { store.saveDevice( DeviceStore.deviceKey(lastHost, lastPort, store.deviceId()), finalName == null ? lastHost : finalName, lastHost, lastPort, finalToken); refreshDeviceList(); } if (serverLabel != null) { serverLabel.setText("已连接 " + (finalName == null ? lastHost : finalName) + "(中转)"); } appendLog("已切到中转服务器,连接恢复"); setStatus("已切到中转(局域网不可用)", C_OK); showControlPanel(); } else { autoRetry = 0; lanDirect = false; setStatus("中转也连不上:" + finalError, C_DANGER); appendLog("切中转失败:" + finalError); showConnectPanel(); } } }); } }); } // ================================================================== // 命令发送 // ================================================================== private void sendCommand(final Map message, final String label) { io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(message, 15000); final boolean ok = Json.bool(reply, "ok", false); final String note = ok ? Json.str(reply, "note", label + " 已执行") : Json.str(reply, "error", "执行失败"); ui.post(new Runnable() { @Override public void run() { appendLog(note); if (!ok) { toast(note); } } }); } catch (final IOException e) { postError(label, e); } } }); } private void postError(final String label, final IOException e) { ui.post(new Runnable() { @Override public void run() { String message = e.getMessage() == null ? "网络错误" : e.getMessage(); appendLog(label + " 失败:" + message); toast(label + " 失败"); } }); } private void sendMedia(String action, String label) { sendCommand(Json.obj("cmd", "media", "action", action), label); } private void sendPower(String action, String label) { sendCommand(Json.obj("cmd", "power", "action", action), label); } // ================================================================== // 各类操作 // ================================================================== private void promptClipboardToPc() { promptInput("发送到电脑剪贴板", "要复制到电脑的文字", "", new InputHandler() { @Override public void onInput(String text) { if (!text.isEmpty()) { sendCommand(Json.obj("cmd", "clip_set", "text", text), "剪贴板"); } } }); } private void fetchClipboard() { io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(Json.obj("cmd", "clip_get"), 15000); final String text = Json.str(reply, "text", ""); ui.post(new Runnable() { @Override public void run() { if (text.isEmpty()) { toast("电脑剪贴板是空的"); return; } copyToWatchClipboard(text); alert("电脑剪贴板", text); } }); } catch (final IOException e) { postError("取剪贴板", e); } } }); } @SuppressWarnings("deprecation") private void copyToWatchClipboard(String text) { try { android.content.ClipboardManager manager = (android.content.ClipboardManager) getSystemService(CLIPBOARD_SERVICE); if (manager != null) { manager.setPrimaryClip(android.content.ClipData.newPlainText("lanremote", text)); } } catch (Exception ignored) { } } /** 选择电脑上的 TTS 音色。 */ private void pickVoice() { appendLog("正在获取电脑音色…"); io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(Json.obj("cmd", "voice_list"), 12000); final List voices = stringList(reply, "voices"); ui.post(new Runnable() { @Override public void run() { if (voices.isEmpty()) { toast("电脑上没读到音色,或没连上"); return; } List labels = new ArrayList(); labels.add("系统默认"); labels.addAll(voices); showChooser("选择播报音色", labels, new Chooser() { @Override public void onPick(int index) { String chosen = index == 0 ? "" : voices.get(index - 1); sendCommand(Json.obj("cmd", "set_voice", "voice", chosen), "设置音色"); } }); } }); } catch (final IOException e) { postError("获取音色", e); } } }); } /** 选一个电脑上的音频文件直接播放。 */ private void pickLocalAudio() { appendLog("正在获取电脑音乐…"); io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(Json.obj("cmd", "audio_list"), 15000); final List names = stringList(reply, "names"); final List paths = stringList(reply, "paths"); ui.post(new Runnable() { @Override public void run() { if (names.isEmpty() || names.size() != paths.size()) { toast("电脑音乐目录里没有音频文件"); return; } showChooser("选择要播放的音乐", names, new Chooser() { @Override public void onPick(int index) { sendCommand(Json.obj("cmd", "play_local", "path", paths.get(index)), "播放 " + names.get(index)); } }); } }); } catch (final IOException e) { postError("获取音乐列表", e); } } }); } /** 把回复里的字符串数组取出来。 */ @SuppressWarnings("unchecked") private static List stringList(Map reply, String key) { List out = new ArrayList(); try { Object raw = reply == null ? null : reply.get(key); if (raw instanceof List) { for (Object item : (List) raw) { if (item != null) { out.add(String.valueOf(item)); } } } } catch (Exception ignored) { // 结构不对就返回空 } return out; } private void promptSay() { promptInput("让电脑语音播报", "要播报的文字", "", new InputHandler() { @Override public void onInput(String text) { if (!text.isEmpty()) { sendCommand(Json.obj("cmd", "say", "text", text), "语音播报"); } } }); } private void promptNotify() { promptInput("在电脑上弹通知", "通知内容", "", new InputHandler() { @Override public void onInput(String text) { if (!text.isEmpty()) { sendCommand(Json.obj("cmd", "notify", "title", "来自手表", "text", text), "通知"); } } }); } private void confirmPower(final String action, String label) { new AlertDialog.Builder(this) .setTitle("确认" + label) .setMessage("确定要让电脑执行「" + label + "」吗?") .setPositiveButton("确定", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface dialog, int which) { sendPower(action, action); } }) .setNegativeButton("取消", null) .show(); } private void promptRun() { promptInput("执行自定义命令", "例如 notepad.exe 或 dir", "", new InputHandler() { @Override public void onInput(String text) { if (text.isEmpty()) { return; } final String command = text; io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(Json.obj("cmd", "run", "command", command), 90000); final boolean ok = Json.bool(reply, "ok", false); final String stdout = Json.str(reply, "stdout", ""); final String stderr = Json.str(reply, "stderr", ""); final int code = Json.integer(reply, "code", -1); final String error = Json.str(reply, "error", ""); ui.post(new Runnable() { @Override public void run() { if (!ok) { alert("命令失败", error); return; } StringBuilder sb = new StringBuilder(); sb.append("退出码: ").append(code).append("\n\n"); if (!stdout.isEmpty()) { sb.append(stdout); } if (!stderr.isEmpty()) { sb.append("\n[stderr]\n").append(stderr); } if (stdout.isEmpty() && stderr.isEmpty()) { sb.append("(无输出)"); } alert("命令输出", sb.toString()); } }); } catch (final IOException e) { postError("命令", e); } } }); } }); } private void showInfo() { io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(Json.obj("cmd", "info"), 15000); final Map info = Json.asMap(reply.get("info")); ui.post(new Runnable() { @Override public void run() { if (info == null) { alert("系统信息", "未取到信息"); return; } StringBuilder sb = new StringBuilder(); sb.append("电脑名称: ").append(Json.str(info, "server_name", "-")).append("\n"); sb.append("系统: ").append(Json.str(info, "os", "-")).append("\n"); sb.append("端口: ").append(Json.integer(info, "port", 0)).append("\n"); sb.append("允许命令: ") .append(Json.bool(info, "allow_run", false) ? "是" : "否").append("\n"); sb.append("允许电源: ") .append(Json.bool(info, "allow_power", false) ? "是" : "否"); alert("系统信息", sb.toString()); } }); } catch (final IOException e) { postError("系统信息", e); } } }); } // ================================================================== // 文件 // ================================================================== private void pickFile(final int requestCode, final String mime) { // 启动时已经把媒体权限要好了 -> 用内置选择器,不再弹系统窗口 if (hasMediaPermission()) { List items; String title; if (requestCode == REQ_IMAGE) { items = MediaPicker.listImages(this, 40); title = "选择图片"; } else if (requestCode == REQ_AUDIO) { items = MediaPicker.listAudio(this, 40); title = "选择音频"; } else { items = MediaPicker.listFiles(this, 40); title = "选择文件"; } if (!items.isEmpty()) { MediaPicker.show(this, title, items, new MediaPicker.OnPick() { @Override public void onPick(MediaPicker.Item item) { uploadUri(item.uri, requestCode); } }, new Runnable() { @Override public void run() { pickWithSystem(requestCode, mime); } }); return; } } // 权限被拒或没有可列出的文件时,回退到系统选择器 pickWithSystem(requestCode, mime); } private void pickWithSystem(int requestCode, String mime) { try { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType(mime); startActivityForResult(intent, requestCode); } catch (Exception e) { toast("这台设备没有可用的文件选择器"); } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode != RESULT_OK || data == null || data.getData() == null) { return; } uploadUri(data.getData(), requestCode); } /** 把选中的文件上传到被控端。内置选择器和系统选择器共用这段逻辑。 */ private void uploadUri(final Uri uri, int requestCode) { final String name = queryFileName(uri); final String command; final String label; if (requestCode == REQ_IMAGE) { command = "image"; label = "投图"; } else if (requestCode == REQ_AUDIO) { command = "audio"; label = "放音"; } else { command = "file_push"; label = "发送文件"; } appendLog(label + ":" + name + " 上传中…"); io.execute(new Runnable() { @Override public void run() { try { byte[] blob = readAll(uri); if (blob == null) { throw new IOException("无法读取文件"); } final Map reply = client.sendChunks(command, name, blob, 120000); final boolean ok = Json.bool(reply, "ok", false); final String note = ok ? Json.str(reply, "note", label + " 完成") : Json.str(reply, "error", label + " 失败"); ui.post(new Runnable() { @Override public void run() { appendLog(note); toast(note); } }); } catch (final IOException e) { postError(label, e); } } }); } /** 单个文件最大 32MB。整文件读进内存再 Base64 会放大好几倍, * 选个大视频就直接 OOM —— 而 OOM 是 Error,catch(IOException) 抓不住。 */ private static final int MAX_UPLOAD_BYTES = 32 * 1024 * 1024; private byte[] readAll(Uri uri) { InputStream input = null; ByteArrayOutputStream buffer = new ByteArrayOutputStream(); try { input = getContentResolver().openInputStream(uri); if (input == null) { return null; } byte[] chunk = new byte[64 * 1024]; int read; while ((read = input.read(chunk)) > 0) { if (buffer.size() + read > MAX_UPLOAD_BYTES) { return null; // 超限,上层会提示文件太大 } buffer.write(chunk, 0, read); } return buffer.toByteArray(); } catch (IOException e) { return null; } finally { if (input != null) { try { input.close(); } catch (IOException ignored) { } } } } private String queryFileName(Uri uri) { String name = null; Cursor cursor = null; try { cursor = getContentResolver().query(uri, null, null, null, null); if (cursor != null && cursor.moveToFirst()) { int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); if (index >= 0) { name = cursor.getString(index); } } } catch (Exception ignored) { } finally { if (cursor != null) { cursor.close(); } } if (name == null || name.isEmpty()) { String path = uri.getLastPathSegment(); name = path == null ? "file.bin" : path.substring(path.lastIndexOf('/') + 1); if (name.isEmpty()) { name = "file.bin"; } } return name; } private void showRemoteFiles() { appendLog("读取电脑文件列表…"); io.execute(new Runnable() { @Override public void run() { try { final Map reply = client.call(Json.obj("cmd", "file_list"), 20000); final List files = Json.list(reply, "files"); final String[] names = new String[files.size()]; final long[] sizes = new long[files.size()]; for (int i = 0; i < files.size(); i++) { Map file = Json.asMap(files.get(i)); names[i] = file == null ? "?" : Json.str(file, "name", "?"); sizes[i] = file == null ? 0 : Json.integer(file, "size", 0); } ui.post(new Runnable() { @Override public void run() { if (names.length == 0) { alert("电脑文件", "电脑接收目录里还没有文件"); return; } String[] labels = new String[names.length]; for (int i = 0; i < names.length; i++) { labels[i] = names[i] + "\n" + humanSize(sizes[i]); } new AlertDialog.Builder(MainActivity.this) .setTitle("电脑上的文件(点击接收)") .setItems(labels, new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface dialog, int which) { pullFile(names[which]); } }) .setNegativeButton("关闭", null) .show(); } }); } catch (final IOException e) { postError("文件列表", e); } } }); } private void pullFile(final String name) { incoming = new ByteArrayOutputStream(); incomingName = name; appendLog("接收 " + name + " …"); io.execute(new Runnable() { @Override public void run() { try { Map reply = client.call(Json.obj("cmd", "file_pull", "path", name), 180000); if (!Json.bool(reply, "ok", false)) { final String error = Json.str(reply, "error", "接收失败"); ui.post(new Runnable() { @Override public void run() { appendLog(error); toast(error); } }); } } catch (final IOException e) { postError("接收文件", e); } } }); } private void handleIncomingFile(Map message) { byte[] chunk = B64.decode(Json.str(message, "data", "")); boolean last = Json.bool(message, "last", false); ByteArrayOutputStream buffer = incoming; if (buffer == null) { buffer = new ByteArrayOutputStream(); incoming = buffer; } try { buffer.write(chunk); } catch (IOException ignored) { } if (last) { final byte[] blob = buffer.toByteArray(); final String name = incomingName.isEmpty() ? "received.bin" : incomingName; incoming = null; incomingName = ""; final File saved = saveToStorage(name, blob); ui.post(new Runnable() { @Override public void run() { if (saved == null) { appendLog("保存 " + name + " 失败"); toast("保存失败"); } else { appendLog("已保存 " + name + " (" + humanSize(blob.length) + ")"); toast("已保存到 " + saved.getName()); } } }); } } private File saveToStorage(String name, byte[] blob) { File dir = getExternalFilesDir(null); if (dir == null) { dir = getFilesDir(); } File target = new File(dir, name); FileOutputStream output = null; try { output = new FileOutputStream(target); output.write(blob); output.flush(); return target; } catch (IOException e) { return null; } finally { if (output != null) { try { output.close(); } catch (IOException ignored) { } } } } private void buildMirrorPanel() { mirrorPanel = new FrameLayout(this); mirrorPanel.setBackgroundColor(0xFFE8EAED); mirrorImage = new ImageView(this); mirrorImage.setScaleType(ImageView.ScaleType.FIT_CENTER); mirrorPanel.addView(mirrorImage, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); LinearLayout top = new LinearLayout(this); top.setOrientation(LinearLayout.HORIZONTAL); top.setGravity(Gravity.CENTER_VERTICAL); top.setBackgroundColor(0xE6FFFFFF); top.setPadding(dp(8), dp(6), dp(8), dp(6)); mirrorStatus = new TextView(this); mirrorStatus.setText("屏幕镜像"); mirrorStatus.setTextColor(C_FG); mirrorStatus.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); top.addView(mirrorStatus, new LinearLayout.LayoutParams( 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); top.addView(smallButton("截屏保存", C_ACCENT, new Runnable() { public void run() { doScreenshot(); } })); top.addView(smallButton("停止", C_DANGER, new Runnable() { public void run() { closeMirrorPanel(); } })); mirrorPanel.addView(top, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP)); mirrorPanel.setVisibility(View.GONE); } private Button smallButton(String text, int color, final Runnable task) { Button button = new Button(this); button.setText(text); button.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); button.setTextColor(readableOn(color)); button.setBackground(glassButton(color, 12)); attachPress(button); button.setAllCaps(false); button.setPadding(dp(8), 0, dp(8), 0); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, dp(40)); params.setMargins(dp(4), 0, 0, 0); button.setLayoutParams(params); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { task.run(); } }); return button; } private void openMirrorPanel() { if (!ensureConnected()) { return; } connectPanel.setVisibility(View.GONE); controlPanel.setVisibility(View.GONE); mirrorPanel.setVisibility(View.VISIBLE); animateIn(mirrorPanel, 24f, 260); getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING); frameCount = 0; currentFps = 0f; fpsWindowStart = System.currentTimeMillis(); mirrorStatus.setText("正在开启屏幕共享…"); io.execute(new Runnable() { @Override public void run() { try { Map reply = client.call(Json.obj( "cmd", "screen_start", "fps", 4, "maxWidth", 480, "quality", 55), 25000); final boolean ok = Json.bool(reply, "ok", false); final String note = ok ? Json.str(reply, "note", "屏幕共享已开始") : Json.str(reply, "error", "开启失败"); mirrorStreaming = ok; ui.post(new Runnable() { @Override public void run() { mirrorStatus.setText(note); if (!ok) { toast(note); } appendLog(note); } }); } catch (final IOException e) { postError("屏幕镜像", e); } } }); } private void closeMirrorPanel() { if (mirrorStreaming) { mirrorStreaming = false; io.execute(new Runnable() { @Override public void run() { try { client.call(Json.obj("cmd", "screen_stop"), 12000); } catch (IOException ignored) { // 连接已断开时无需处理 } } }); } animateOut(mirrorPanel, 24f, 200, new Runnable() { @Override public void run() { mirrorPanel.setVisibility(View.GONE); } }); if (mirrorImage != null) { mirrorImage.setImageBitmap(null); } if (controlPanel.getVisibility() != View.VISIBLE && client.isConnected()) { controlPanel.setVisibility(View.VISIBLE); } } private void handleFrame(Map message) { byte[] jpeg = B64.decode(Json.str(message, "data", "")); if (jpeg.length == 0) { return; } Bitmap decoded = null; try { decoded = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length); } catch (Exception ignored) { // 单帧解码失败直接丢弃,不影响后续帧 } frameCount++; long now = System.currentTimeMillis(); if (now - fpsWindowStart >= 1000) { currentFps = frameCount * 1000f / Math.max(1, now - fpsWindowStart); frameCount = 0; fpsWindowStart = now; } final Bitmap frame = decoded; final float fps = currentFps; ui.post(new Runnable() { @Override public void run() { if (mirrorPanel.getVisibility() != View.VISIBLE) { return; } if (frame != null && mirrorImage != null) { mirrorImage.setImageBitmap(frame); } mirrorStatus.setText(String.format(Locale.getDefault(), "屏幕镜像 · %.1f fps", fps)); } }); } private void handleScreenStopped(Map message) { mirrorStreaming = false; final String reason = Json.str(message, "reason", "屏幕共享已结束"); ui.post(new Runnable() { @Override public void run() { appendLog(reason); toast(reason); if (mirrorPanel.getVisibility() == View.VISIBLE) { mirrorStatus.setText(reason); } } }); } private void doScreenshot() { if (!ensureConnected()) { return; } // 截屏结果直接在镜像面板里显示 if (mirrorPanel.getVisibility() != View.VISIBLE) { connectPanel.setVisibility(View.GONE); controlPanel.setVisibility(View.GONE); mirrorPanel.setVisibility(View.VISIBLE); } mirrorStatus.setText("正在截屏…"); appendLog("正在截屏…"); io.execute(new Runnable() { @Override public void run() { try { Map reply = client.call(Json.obj( "cmd", "screenshot", "maxWidth", 1280, "quality", 85), 40000); if (!Json.bool(reply, "ok", false)) { final String error = Json.str(reply, "error", "截屏失败"); ui.post(new Runnable() { @Override public void run() { mirrorStatus.setText(error); appendLog(error); toast(error); } }); return; } byte[] jpeg = B64.decode(Json.str(reply, "data", "")); final String name = Json.str(reply, "name", "screenshot.jpg"); final int width = Json.integer(reply, "w", 0); final int height = Json.integer(reply, "h", 0); Bitmap bitmap = null; try { bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length); } catch (Exception ignored) { // 解码失败仍然保存原始文件 } final File saved = saveToStorage(name, jpeg); final Bitmap shot = bitmap; ui.post(new Runnable() { @Override public void run() { if (shot != null && mirrorImage != null) { mirrorImage.setImageBitmap(shot); } mirrorStatus.setText(String.format(Locale.getDefault(), "截屏 %dx%d · %s", width, height, humanSize( jpeg != null ? jpeg.length : 0))); String note = saved == null ? "截屏已获取,但保存失败" : "已保存 " + saved.getName() + "(" + humanSize(jpeg != null ? jpeg.length : 0) + ")"; appendLog(note); toast(note); } }); } catch (final IOException e) { postError("截屏", e); } } }); } // ================================================================== // 权限(启动时一次要齐) // ================================================================== private void requestStartupPermissions() { List needed = new ArrayList(); if (Build.VERSION.SDK_INT >= 33) { needed.add("android.permission.READ_MEDIA_IMAGES"); needed.add("android.permission.READ_MEDIA_AUDIO"); needed.add("android.permission.READ_MEDIA_VIDEO"); } else { needed.add(Manifest.permission.READ_EXTERNAL_STORAGE); } List missing = new ArrayList(); for (String permission : needed) { if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { missing.add(permission); } } if (missing.isEmpty()) { appendLog("权限已就绪,选图/选音频不会再弹窗"); return; } requestPermissions(missing.toArray(new String[missing.size()]), REQ_PERMS); } @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode != REQ_PERMS) { return; } int granted = 0; for (int result : grantResults) { if (result == PackageManager.PERMISSION_GRANTED) { granted++; } } if (grantResults.length > 0 && granted == grantResults.length) { appendLog("权限已全部授予,之后操作不会再弹窗"); } else { appendLog("部分权限被拒绝(" + granted + "/" + grantResults.length + ")"); appendLog("仍可手动选文件,只是会弹出系统选择器"); } } private boolean hasMediaPermission() { String permission = Build.VERSION.SDK_INT >= 33 ? "android.permission.READ_MEDIA_IMAGES" : Manifest.permission.READ_EXTERNAL_STORAGE; return checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED; } // ================================================================== // 通用对话框 / 提示 // ================================================================== private interface InputHandler { void onInput(String text); } private void promptInput(String title, String hint, String initial, final InputHandler handler) { final EditText input = new EditText(this); input.setHint(hint); input.setText(initial); input.setTextColor(C_FG); input.setHintTextColor(C_DIM); input.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15); input.setBackground(rounded(C_CARD, 7)); input.setPadding(dp(10), dp(10), dp(10), dp(10)); LinearLayout box = new LinearLayout(this); box.setPadding(dp(12), dp(8), dp(12), dp(4)); box.addView(input, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); new AlertDialog.Builder(this) .setTitle(title) .setView(box) .setPositiveButton("发送", new android.content.DialogInterface.OnClickListener() { @Override public void onClick(android.content.DialogInterface dialog, int which) { handler.onInput(input.getText().toString().trim()); } }) .setNegativeButton("取消", null) .show(); } private void alert(String title, String message) { ScrollView scroll = new ScrollView(this); TextView text = new TextView(this); text.setText(message); text.setTextColor(C_FG); text.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13); text.setPadding(dp(12), dp(8), dp(12), dp(8)); text.setTextIsSelectable(true); scroll.addView(text); new AlertDialog.Builder(this) .setTitle(title) .setView(scroll) .setPositiveButton("知道了", null) .show(); } private void toast(String text) { Toast.makeText(this, text, Toast.LENGTH_SHORT).show(); } private void appendLog(final String text) { ui.post(new Runnable() { @Override public void run() { String stamp = new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(new java.util.Date()); logBuffer.insert(0, "[" + stamp + "] " + text + "\n"); if (logBuffer.length() > 1200) { logBuffer.setLength(1200); } if (logView != null) { logView.setText(logBuffer.toString()); } } }); } private static String shorten(String text, int max) { if (text == null) { return ""; } String flat = text.replace("\n", " "); return flat.length() <= max ? flat : flat.substring(0, max) + "…"; } private 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); } }