Files
ykxmz/android/src/com/lanremote/watch/MainActivity.java
T
xmz7891 b1119d0da6 修「局域网连接被中转劫持」+ 长轮询 15s→60s
用户报两个问题:
  1. 局域网能搜到电脑,IP/端口/配对码都对,点连接**没有任何反应**
  2. 走中转 1426ms 延时,一会儿就 502

========== 一、局域网被劫持(真 bug)==========

MainActivity.connect() 里:

    boolean usedLan = !relayUrl().isEmpty() ? false : true;
    if (!relay.isEmpty()) {
        reply = client.connectViaRelay(...);      // 走中转
    } else {
        reply = client.connect(host, port, ...);  // 才走局域网
    }

**只看「中转设置里填没填地址」,填了就一律走中转,
完全无视用户填的局域网 IP 和端口。**

而中转单次超时是 40 秒 —— 用户点完就卡在「正在连接…」
四十秒,在他看来就是"没有任何反应"。

影响两条路:
  * 「局域网连接」页面填 IP 直连(connectLanTyped)
  * 首页「已配对的电脑」点一下重连(quickConnect)

修法:给 connect() 加 preferLan 参数。明确要走局域网时
**先直连(6 秒超时,局域网本该是毫秒级),失败再退回中转**,
保证最终能连上。

顺带两个体验改进:
  * 设备列表里,保存的 IP 和手表不同网段时标「⚠ 地址可能已过期」
    (用户截图里那个 192.168.2.64 就是过期地址,电脑早换成
      192.168.10.103 了)
  * quickConnect 判断出地址过期就跳过直连,直接走中转查最新地址 ——
    省掉那 6 秒白等

========== 二、长轮询 15 秒 -> 60 秒 ==========

实测隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源):

    本机直连 60 秒的 poll  ->  60.0s
    走公网同一个 poll      ->  61.5~62.0s

这个开销是**按次数**收的:
    15 秒一循环 -> 一天 5760 次/端,光路费 2.4~3.2 小时
    60 秒一循环 -> 一天 1440 次/端,光费 0.6~0.8 小时
而且请求越频繁,撞上丢包/重连窗口的概率越大。

**不会让命令变慢**:长轮询是"有数据立刻返回"(device.event 唤醒),
拉长只是减少空转重开。实测 60 秒长轮询穿隧道 4/4 通过;
Cloudflare 单请求上限 100 秒,留了 40 秒余量。

三层同步改(客户端超时必须显著大于服务端 hold):
  * relay/relay.py                    RELAY_POLL 默认 25 -> 60
  * windows/lanremote/relay.py        POLL_TIMEOUT 40 -> 90
  * android .../RelayTransport.java   POLL_TIMEOUT_MS 40000 -> 90000

========== 三、A/B 实测(还在跑更严谨的一轮)==========

短测发现基线 http2+4 虽然 100% 成功,但 **P90 是 16.8 秒**
(每 10 次有 1 次卡十几秒 —— 正是用户说的"没反应"),
而 quic+6 的 P90 只有 2.8 秒。正在用真实长轮询模式
跑 10 分钟对照确认。
2026-10-07 14:11:33 +08:00

3990 lines
164 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<View> cellQueue =
new java.util.ArrayList<View>();
// 正在连接中(避免重复点)
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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String> 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<String, Object> reply =
client.call(Json.obj("cmd", "feature_list"), 25000);
final List<Map<String, Object>> items = mapList(reply, "features");
ui.post(new Runnable() {
@Override
public void run() {
featureCache = items;
featureGroups = new ArrayList<String>();
for (Map<String, Object> 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<Map<String, Object>> items =
new ArrayList<Map<String, Object>>();
for (Map<String, Object> 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<String, Object> 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<String, Object> reply =
client.call(Json.obj("cmd", "feature_list"), 20000);
final List<Map<String, Object>> items = mapList(reply, "features");
final List<String> groups = new ArrayList<String>();
for (Map<String, Object> 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<String>(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<String> labels = new ArrayList<String>();
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<String> labels = new ArrayList<String>();
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<String, Object> reply =
client.call(Json.obj("cmd", "proc_list"), 20000);
final List<String> names = stringList(reply, "names");
final List<String> 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<String, Object> request,
final String field) {
appendLog("正在获取" + title + "…");
io.execute(new Runnable() {
@Override
public void run() {
try {
final Map<String, Object> 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<String, Object> reply =
client.call(Json.obj("cmd", "clip_history"), 12000);
final List<String> texts = stringList(reply, "texts");
final List<String> times = stringList(reply, "times");
ui.post(new Runnable() {
@Override
public void run() {
if (texts.isEmpty()) {
toast("电脑剪贴板还没有历史");
return;
}
List<String> labels = new ArrayList<String>();
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<Map<String, Object>> featureCache = null;
private List<String> 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<String, Object> reply =
client.call(Json.obj("cmd", "feature_list"), 20000);
featureCache = mapList(reply, "features");
}
final List<Map<String, Object>> items =
new ArrayList<Map<String, Object>>();
List<String> labels = new ArrayList<String>();
for (Map<String, Object> item : featureCache) {
if (!group.equals(Json.str(item, "group", ""))) {
continue;
}
items.add(item);
labels.add(Json.str(item, "label", "?"));
}
final List<String> 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<String, Object> 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<String> labels = new ArrayList<String>();
final List<Integer> values = new ArrayList<Integer>();
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<String, Object> 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<String, Object> params,
final String label) {
appendLog("执行:" + label);
io.execute(new Runnable() {
@Override
public void run() {
try {
Map<String, Object> request = Json.obj("cmd", "feature_run",
"name", name);
request.put("params", params);
final Map<String, Object> 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<Map<String, Object>> mapList(Map<String, Object> reply, String key) {
List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
try {
Object raw = reply == null ? null : reply.get(key);
if (raw instanceof List) {
for (Object item : (List<Object>) raw) {
if (item instanceof Map) {
out.add((Map<String, Object>) 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<DiscoveryCore.Found> found;
try {
found = Discovery.search(MainActivity.this, 4000);
} catch (Throwable error) {
found = new java.util.ArrayList<DiscoveryCore.Found>();
appendLog("搜索失败:" + error);
}
final java.util.List<DiscoveryCore.Found> 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 网段。
*
* <p>读不到手表自己的网卡时返回 true(保守:宁可按原地址试一次,
* 也不要因为拿不到权限就误判成"过期",那会让本可以直连的场景
* 白走一趟中转)。
*/
private boolean hostInMySubnet(String host) {
if (host == null || host.isEmpty()) {
return true;
}
java.util.List<String> mine = localIpv4List();
if (mine.isEmpty()) {
return true;
}
for (String myIp : mine) {
if (sameSubnet(host, myIp)) {
return true;
}
}
return false;
}
/** 手表自己在局域网里的 IPv4 地址(可能多张网卡)。拿不到就空。 */
private java.util.List<String> localIpv4List() {
java.util.List<String> found = new java.util.ArrayList<String>();
try {
java.util.Enumeration<java.net.NetworkInterface> 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<java.net.InetAddress> 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<String> 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<String> deviceIps) {
if (deviceIps == null || deviceIps.isEmpty()) {
return null;
}
java.util.List<String> 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 明确要求"先走局域网"。
*
* <p>用户点「局域网连接」填了 IP,或者点首页已配对电脑时,
* 应该先按他给的地址直连。原来的实现只看中转地址填没填 ——
* 填了就一律走中转,**完全无视用户填的局域网 IP**,
* 而中转超时 40 秒,用户点完只看到「正在连接…」,以为没反应。
*
* <p>设 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<String, Object> 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<String, Object> 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<String> theirs = client.relayLanIps();
java.util.List<String> 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);
}
}
});
}
});
}
/**
* 把连接异常翻译成用户能看懂的话。
*
* <p>手表上原本只会显示一长串英文类名(比如
* {@code SSLHandshakeException: Chain validation failed}),
* 用户完全不知道该怎么办。这里按常见情况给出可操作的提示。
*
* <p>证书类问题交给 {@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<String, Object> 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<String, Object> 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<String, Object> message, final String label) {
io.execute(new Runnable() {
@Override
public void run() {
try {
final Map<String, Object> 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<String, Object> 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<String, Object> reply =
client.call(Json.obj("cmd", "voice_list"), 12000);
final List<String> voices = stringList(reply, "voices");
ui.post(new Runnable() {
@Override
public void run() {
if (voices.isEmpty()) {
toast("电脑上没读到音色,或没连上");
return;
}
List<String> labels = new ArrayList<String>();
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<String, Object> reply =
client.call(Json.obj("cmd", "audio_list"), 15000);
final List<String> names = stringList(reply, "names");
final List<String> 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<String> stringList(Map<String, Object> reply, String key) {
List<String> out = new ArrayList<String>();
try {
Object raw = reply == null ? null : reply.get(key);
if (raw instanceof List) {
for (Object item : (List<Object>) 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<String, Object> 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<String, Object> reply = client.call(Json.obj("cmd", "info"), 15000);
final Map<String, Object> 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<MediaPicker.Item> 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<String, Object> 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<String, Object> reply =
client.call(Json.obj("cmd", "file_list"), 20000);
final List<Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String> needed = new ArrayList<String>();
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<String> missing = new ArrayList<String>();
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);
}
}