mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 13:40:45 +00:00
用户报两个问题:
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 分钟对照确认。
3990 lines
164 KiB
Java
3990 lines
164 KiB
Java
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);
|
||
}
|
||
}
|