feat(controlled, controller): 新增配对码生成与兑换功能

被控端:新增生成一次性配对码并弹窗展示,支持倒计时与复制;控制端:新增输入配对码兑换绑定,刷新设备列表,并在设备列表中显示在线状态和点击连接。同时添加统一 401 拦截器,令牌失效时触发重新激活。
This commit is contained in:
TongTongStudio
2026-08-03 01:57:42 +08:00
parent ded6fe5ab7
commit e689b999e6
29 changed files with 1096 additions and 86 deletions

View File

@@ -5,19 +5,25 @@ import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.app.AlertDialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ServiceConnection;
import android.content.pm.PackageManager;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.CountDownTimer;
import android.os.IBinder;
import android.util.Log;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -29,12 +35,18 @@ import com.ttstd.controlled.accessibility.AccessibilityServiceHelper;
import com.ttstd.controlled.base.mvvm.BaseMvvmActivity;
import com.ttstd.controlled.databinding.ActivityMainBinding;
import com.ttstd.controlled.activity.settings.SettingsActivity;
import com.ttstd.controlled.network.DeviceRepository;
import com.ttstd.controlled.network.model.PairingCodeResponse;
import com.ttstd.controlled.service.ScreenCaptureService;
import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.webrtc.WebRtcClient;
import org.webrtc.RendererCommon;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBinding>
implements ScreenCaptureService.ServiceStateListener {
@@ -50,6 +62,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
/** 是否为特权应用(系统签名或无 root 权限),缓存后避免重复在主线程执行 su 检测。 */
private boolean isPrivileged = false;
private AlertDialog accessibilityDialog;
/** 配对码生成请求的订阅,用于界面销毁时释放。 */
private Disposable pairingDisposable;
/** 当前配对码倒计时,用于界面销毁时取消。 */
private CountDownTimer pairingCountDown;
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
@@ -93,6 +109,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.btnOpenSettings.setOnClickListener(v ->
startActivity(new Intent(this, SettingsActivity.class)));
// 生成一次性配对码,供其他用户(控制端)兑换绑定
binding.btnGeneratePairing.setOnClickListener(v -> onGeneratePairingCode());
updateUI(false);
}
@@ -239,6 +258,96 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
Toast.makeText(this, "屏幕共享已停止", Toast.LENGTH_SHORT).show();
}
/**
* 生成一次性配对码并弹窗展示。配对码由服务端生成,明文仅回显一次,
* 有效期默认 10 分钟;过期后需重新生成。复制按钮便于用户分享给他人。
*/
private void onGeneratePairingCode() {
DeviceRepository repository = new DeviceRepository(this);
if (pairingDisposable != null && !pairingDisposable.isDisposed()) {
pairingDisposable.dispose();
}
pairingDisposable = repository.generatePairingCode()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
this::showPairingCodeDialog,
err -> Toast.makeText(this,
"生成配对码失败:" + err.getMessage(), Toast.LENGTH_LONG).show()
);
}
/** 弹窗展示配对码、剩余有效期与复制按钮。 */
private void showPairingCodeDialog(PairingCodeResponse resp) {
if (resp == null || resp.getCode() == null || resp.getCode().isEmpty()) {
Toast.makeText(this, "生成配对码失败:服务端未返回有效码", Toast.LENGTH_LONG).show();
return;
}
final String code = resp.getCode();
int totalSec = resp.getExpiresInSeconds() > 0 ? resp.getExpiresInSeconds() : 600;
TextView tvCode = new TextView(this);
tvCode.setText(code);
tvCode.setTextSize(32);
tvCode.setPadding(40, 30, 40, 10);
tvCode.setTextIsSelectable(true);
TextView tvTip = new TextView(this);
tvTip.setPadding(40, 0, 40, 20);
tvTip.setText("请在其他设备的控制端输入此配对码以完成绑定(10分钟内有效)");
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("邀请绑定 - 配对码")
.setView(composePairingView(tvCode, tvTip))
.setPositiveButton("复制", (d, w) -> {
ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (cm != null) {
cm.setPrimaryClip(ClipData.newPlainText("pairing_code", code));
Toast.makeText(this, "配对码已复制", Toast.LENGTH_SHORT).show();
}
})
.setNegativeButton("关闭", (d, w) -> {
if (pairingCountDown != null) pairingCountDown.cancel();
d.dismiss();
})
.setCancelable(false)
.create();
// 倒计时更新提示文案
final long tickMs = 1000;
pairingCountDown = new CountDownTimer(totalSec * 1000L, tickMs) {
@Override
public void onTick(long millisUntilFinished) {
long sec = TimeUnit.MILLISECONDS.toSeconds(millisUntilFinished);
tvTip.setText(String.format(Locale.getDefault(),
"请在其他设备的控制端输入此配对码以完成绑定(剩余 %d 秒)", sec));
}
@Override
public void onFinish() {
tvTip.setText("配对码已过期,请重新生成");
tvCode.setEnabled(false);
}
};
dialog.setOnDismissListener(d -> {
if (pairingCountDown != null) {
pairingCountDown.cancel();
pairingCountDown = null;
}
});
dialog.show();
pairingCountDown.start();
}
/** 组合配对码展示视图(码 + 提示)。 */
private android.widget.LinearLayout composePairingView(TextView tvCode, TextView tvTip) {
android.widget.LinearLayout layout = new android.widget.LinearLayout(this);
layout.setOrientation(android.widget.LinearLayout.VERTICAL);
layout.addView(tvCode);
layout.addView(tvTip);
return layout;
}
private void setupLocalPreview() {
if (isBound && screenCaptureService != null && !isLocalViewInitialized) {
WebRtcClient webRtcClient = screenCaptureService.getWebRtcClient();
@@ -262,6 +371,14 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
@Override
protected void onDestroy() {
super.onDestroy();
if (pairingDisposable != null && !pairingDisposable.isDisposed()) {
pairingDisposable.dispose();
pairingDisposable = null;
}
if (pairingCountDown != null) {
pairingCountDown.cancel();
pairingCountDown = null;
}
if (accessibilityDialog != null && accessibilityDialog.isShowing()) {
accessibilityDialog.dismiss();
accessibilityDialog = null;

View File

@@ -9,6 +9,8 @@ import androidx.multidex.MultiDex;
import com.tencent.mmkv.MMKV;
import com.ttstd.controlled.network.ChainContext;
public class BaseApplication extends Application {
private static final String TAG = "BaseApplication";
@@ -33,6 +35,7 @@ public class BaseApplication extends Application {
super.onCreate();
Log.e(TAG, "onCreate: ");
mAppContext = getApplicationContext();
ChainContext.init(this);
init();
}

View File

@@ -0,0 +1,23 @@
package com.ttstd.controlled.network;
import android.content.Context;
/**
* 在 OkHttp 拦截器(无 Activity/Context 引用)中获取 Application Context 的简易持有者。
* 由 {@code BaseApplication} 在 onCreate 中初始化。
*/
public final class ChainContext {
private static Context sAppContext;
private ChainContext() {
}
public static void init(Context context) {
sAppContext = context.getApplicationContext();
}
public static Context getAppContext() {
return sAppContext;
}
}

View File

@@ -1,6 +1,7 @@
package com.ttstd.controlled.network;
import com.google.gson.JsonObject;
import com.ttstd.controlled.network.model.PairingCodeResponse;
import com.ttstd.controlled.network.model.ProvisionRequest;
import com.ttstd.controlled.network.model.ProvisionResponse;
import com.ttstd.controlled.network.model.TokenRequest;
@@ -37,4 +38,10 @@ public interface DeviceApi {
*/
@GET("/api/client/turn-credentials")
Single<JsonObject> turnCredentials(@Header("Authorization") String bearerToken);
/**
* 生成一次性配对码,供其他用户(控制端)兑换绑定。需携带设备令牌。
*/
@POST("/api/client/device/pairing-code")
Single<PairingCodeResponse> pairingCode(@Header("Authorization") String bearerToken);
}

View File

@@ -6,6 +6,7 @@ import android.text.TextUtils;
import com.google.gson.JsonObject;
import com.ttstd.controlled.BuildConfig;
import com.ttstd.controlled.network.model.PairingCodeResponse;
import com.ttstd.controlled.network.model.ProvisionRequest;
import com.ttstd.controlled.network.model.ProvisionResponse;
import com.ttstd.controlled.network.model.TokenRequest;
@@ -13,6 +14,7 @@ import com.ttstd.controlled.network.model.TokenResponse;
import com.ttstd.controlled.utils.DeviceSecretStore;
import com.ttstd.controlled.utils.DeviceUtils;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -66,7 +68,8 @@ public class DeviceRepository {
return exchangeToken(store.getDeviceUid(), store.getDeviceSecret());
}
return provision().flatMap(resp -> exchangeToken(resp.getDeviceUid(), resp.getDeviceSecret()));
}).subscribeOn(Schedulers.io());
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/** 强制重新激活:清空本地凭据后走完整 provision 流程。用于激活页「刷新重试」。 */
@@ -74,7 +77,8 @@ public class DeviceRepository {
return Single.defer(() -> {
store.clear();
return provision().flatMap(resp -> exchangeToken(resp.getDeviceUid(), resp.getDeviceSecret()));
}).subscribeOn(Schedulers.io());
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/** 第一步:用 SN + HMAC 证明出厂身份,成功后立即加密落盘。 */
@@ -120,6 +124,21 @@ public class DeviceRepository {
}
return api.turnCredentials("Bearer " + accessToken)
.onErrorReturnItem(new JsonObject())
.subscribeOn(Schedulers.io());
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/**
* 生成一次性配对码,供其他用户(控制端)兑换绑定。
* 需设备已激活且持有有效的 accessToken。
*/
public Single<PairingCodeResponse> generatePairingCode() {
String token = getAccessToken();
if (TextUtils.isEmpty(token)) {
return Single.error(new IllegalStateException("设备尚未激活,无法生成配对码"));
}
return api.pairingCode("Bearer " + token)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
}

View File

@@ -4,6 +4,7 @@ import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.ttstd.controlled.BuildConfig;
import com.ttstd.controlled.network.UnauthorizedInterceptor;
import java.util.concurrent.TimeUnit;
@@ -35,7 +36,8 @@ public final class RetrofitClient {
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true);
.retryOnConnectionFailure(true)
.addInterceptor(new UnauthorizedInterceptor());
if (BuildConfig.DEBUG) {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(HttpLoggingInterceptor.Level.BODY);

View File

@@ -0,0 +1,57 @@
package com.ttstd.controlled.network;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.ttstd.controlled.utils.DeviceSecretStore;
import java.io.IOException;
import okhttp3.Interceptor;
import okhttp3.Response;
/**
* 被控端统一 401 拦截器。
*
* <p>被控端没有「登录页」概念,401 表示服务端下发的 accessToken 已失效。此时应清理本地
* 激活凭据(deviceUid / deviceSecret / accessToken),并通过本地广播通知
* {@code ScreenCaptureService} 重新走激活流程(provision / token 换取)。
*
* <p>注意:令牌失效可能伴随大量并发请求同时返回 401,这里用 {@code notified} 哨兵保证
* 一次失效只清一次凭据、只发一次重激活广播。
*/
public class UnauthorizedInterceptor implements Interceptor {
/** ScreenCaptureService 监听此本地广播以触发重新激活。 */
public static final String ACTION_TOKEN_INVALID = "com.ttstd.controlled.action.TOKEN_INVALID";
private static final String TAG = "UnauthorizedInterceptor";
private static volatile boolean notified = false;
@NonNull
@Override
public Response intercept(@NonNull Chain chain) throws IOException {
Response response = chain.proceed(chain.request());
if (response.code() == 401) {
Log.w(TAG, "收到 401 未授权响应,清理激活凭据并触发重新激活");
if (!notified) {
notified = true;
// 清理本地激活凭据,强制重新激活。
Context appCtx = ChainContext.getAppContext();
if (appCtx != null) {
new DeviceSecretStore(appCtx).clear();
}
if (appCtx != null) {
LocalBroadcastManager.getInstance(appCtx)
.sendBroadcast(new Intent(ACTION_TOKEN_INVALID));
}
notified = false;
}
}
return response;
}
}

View File

@@ -0,0 +1,34 @@
package com.ttstd.controlled.network.model;
import com.google.gson.annotations.SerializedName;
/**
* 被控端生成的一次性配对码响应。
* 对应服务端 {@code POST /api/client/device/pairing-code}。
*/
public class PairingCodeResponse {
/** 一次性配对码,明文仅回显一次,10 分钟有效。 */
@SerializedName("code")
private String code;
/** 有效期(秒)。 */
@SerializedName("expiresInSeconds")
private int expiresInSeconds;
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public int getExpiresInSeconds() {
return expiresInSeconds;
}
public void setExpiresInSeconds(int expiresInSeconds) {
this.expiresInSeconds = expiresInSeconds;
}
}

View File

@@ -6,8 +6,10 @@ import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ServiceInfo;
import android.media.projection.MediaProjection;
import android.os.Binder;
@@ -40,6 +42,7 @@ import com.ttstd.controlled.input.RootShellInputUtils;
import com.ttstd.controlled.input.ShellInputUtils;
import com.ttstd.controlled.input.SystemInputUtils;
import com.ttstd.controlled.signaling.SignalMessage;
import com.ttstd.controlled.network.UnauthorizedInterceptor;
import com.ttstd.controlled.signaling.WebSocketClient;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.utils.InputSettings;
@@ -157,8 +160,30 @@ public class ScreenCaptureService extends BaseService {
instance = this;
createNotificationChannel();
// 401 拦截器发来的令牌失效广播:清理本地令牌后重新走激活流程。
IntentFilter filter = new IntentFilter(UnauthorizedInterceptor.ACTION_TOKEN_INVALID);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(tokenInvalidReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(tokenInvalidReceiver, filter);
}
}
/** 监听 REST 接口 401,触发重新激活。 */
private final BroadcastReceiver tokenInvalidReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Log.w(TAG, "收到令牌失效广播,清理 accessToken 并重新激活");
accessToken = null;
if (pendingServerUrl != null) {
pendingServerUrl = null;
}
// 交由 ViewModel 走 provision / token 重新激活并连接。
mViewModel.activate();
}
};
/**
* 订阅 ViewModel 的激活 / 令牌事件。
* BaseService 实现了 LifecycleOwner,observe 会随 Service 销毁自动解绑。
@@ -718,6 +743,11 @@ public class ScreenCaptureService extends BaseService {
@Override
public void onDestroy() {
super.onDestroy();
try {
unregisterReceiver(tokenInvalidReceiver);
} catch (IllegalArgumentException ignore) {
// 未注册或已注销时忽略
}
isShuttingDown = true;
sResultCode = Activity.RESULT_CANCELED;
sResultData = null;

View File

@@ -39,6 +39,14 @@
android:textSize="16sp"
android:textStyle="bold" />
<!-- 邀请绑定:生成一次性配对码,供其他用户(控制端)兑换绑定 -->
<Button
android:id="@+id/btn_generate_pairing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/btn_generate_pairing" />
<!-- 安全与输入设置入口 -->
<Button
android:id="@+id/btn_open_settings"

View File

@@ -41,6 +41,7 @@
<!-- 设置页面 -->
<string name="settings_title">安全与输入设置</string>
<string name="btn_open_settings">安全与输入设置</string>
<string name="btn_generate_pairing">生成配对码(邀请绑定)</string>
<!-- 模拟点击方式 -->
<string name="input_method_label">模拟点击方式</string>

View File

@@ -12,8 +12,10 @@ import android.view.SurfaceHolder;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.BaseAdapter;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ListView;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;
@@ -33,6 +35,7 @@ import com.ttstd.controller.activity.login.LoginActivity;
import com.ttstd.controller.base.mvvm.BaseMvvmActivity;
import com.ttstd.controller.config.CommonConfig;
import com.ttstd.controller.databinding.ActivityMainBinding;
import com.ttstd.controller.network.UnauthorizedInterceptor;
import com.ttstd.controller.network.model.BindingItem;
import com.ttstd.controller.signaling.SignalMessage;
import com.ttstd.controller.signaling.WebSocketClient;
@@ -86,6 +89,23 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
private String pendingAuthType;
private String pendingAuthValue;
// 已绑定/在线设备列表,用于设备列表展示与选择
private final List<BindingItem> deviceList = new ArrayList<>();
private DeviceListAdapter deviceListAdapter;
// 当前在线设备 uid 集合,用于标记列表中各项的在线/离线状态
private final java.util.Set<String> onlineDeviceSet = new java.util.HashSet<>();
// 连接请求发出后等待对方确认的超时定时器(对方不在线/不确认时给予明确提示)
private static final long CONNECT_WAIT_TIMEOUT_MS = 30_000;
private final Handler connectTimeoutHandler = new Handler(Looper.getMainLooper());
private final Runnable connectTimeoutRunnable = () -> {
if (webRtcClient == null) return;
runOnUiThread(() -> {
binding.tvStatus.setText("状态: 对方未响应,请确认设备已在线并已同意连接");
Toast.makeText(MainActivity.this, "连接请求超时:对方未响应", Toast.LENGTH_LONG).show();
updateUI(false);
});
};
// 心跳定时器(每 25s 发送 PING,配合 OkHttp pingInterval 保活)。
private ScheduledExecutorService heartbeatScheduler;
@@ -113,13 +133,27 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
@Override
protected void initView() {
// 设备ID 由服务端注册后下发(REGISTER_SUCCESS.fromDeviceId),用户无需填写。
binding.etDeviceId.setEnabled(false);
binding.etDeviceId.setHint("连接后由服务端下发");
// 本机信息(用户ID/用户名)由 GET /api/client/verify 获取,无需手动填写。
binding.btnConnect.setOnClickListener(v -> showAuthDialog());
binding.btnDisconnect.setOnClickListener(v -> disconnect());
binding.btnDisconnectControl.setOnClickListener(v -> disconnect());
binding.btnAddDevice.setOnClickListener(v -> showAddDeviceDialog());
// 设备列表(已绑定 + 在线),点击项即选中目标设备并发起连接。
deviceListAdapter = new DeviceListAdapter(deviceList);
binding.listDevices.setAdapter(deviceListAdapter);
binding.listDevices.setOnItemClickListener((parent, view, position, id) -> {
if (position < 0 || position >= deviceList.size()) return;
BindingItem item = deviceList.get(position);
targetDeviceId = item.getDeviceUid();
showAuthDialog();
});
// 刷新按钮:拉取本机信息 + 已绑定设备 + 在线设备
binding.btnRefresh.setOnClickListener(v -> {
if (Boolean.TRUE.equals(viewModel.getRefreshing().getValue())) return;
loadDevices();
});
// 初始化 EGL 和远端视频渲染
eglBase = EglBase.create();
@@ -177,6 +211,16 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
setupRecordUi();
}
@Override
protected void initData() {
// 任意 REST 接口 401 -> 直接回登录页(与 WebSocket 4001/4003 行为一致)。
UnauthorizedInterceptor.setHandler(msg -> {
Toast.makeText(MainActivity.this, msg, Toast.LENGTH_LONG).show();
logoutAndReset();
});
super.initData();
@Override
protected void initData() {
// 令牌就绪 -> 建立信令 WebSocket
@@ -198,16 +242,96 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
logoutAndReset();
});
// 已绑定设备列表
// 已绑定设备列表 -> 刷新设备列表
viewModel.getBindings().observe(this, list -> {
if (list == null || list.isEmpty()) return;
StringBuilder sb = new StringBuilder();
for (BindingItem item : list) {
if (sb.length() > 0) sb.append(", ");
sb.append(item.displayName());
if (list == null || list.isEmpty()) {
deviceList.clear();
deviceListAdapter.notifyDataSetChanged();
updateDeviceEmpty();
return;
}
Toast.makeText(this, "已绑定设备: " + sb, Toast.LENGTH_LONG).show();
mergeDevices(list);
deviceListAdapter.notifyDataSetChanged();
updateDeviceEmpty();
});
// 当前在线(信令网络活跃)设备 -> 仅用于标记列表中各项在线状态
viewModel.getOnlineDevices().observe(this, online -> {
onlineDeviceSet.clear();
if (online != null) {
for (BindingItem d : online) onlineDeviceSet.add(d.getDeviceUid());
}
deviceListAdapter.notifyDataSetChanged();
});
viewModel.getDeviceError().observe(this, err -> {
if (err != null && !err.isEmpty()) {
Toast.makeText(this, err, Toast.LENGTH_SHORT).show();
}
});
// 本机账号信息(用户ID / 用户名)
viewModel.getAccountInfo().observe(this, info -> {
if (info == null) return;
binding.tvAccountId.setText("用户ID: " + (info.getPrincipalId() != null ? info.getPrincipalId() : "-"));
binding.tvAccountName.setText("用户名: " + (info.getDisplayName() != null ? info.getDisplayName() : "-"));
});
viewModel.getAccountError().observe(this, err -> {
if (err != null && !err.isEmpty()) {
Toast.makeText(this, err, Toast.LENGTH_SHORT).show();
}
});
// 刷新状态:避免重复刷新时按钮仍可点
viewModel.getRefreshing().observe(this, refreshing -> {
if (refreshing != null) binding.btnRefresh.setEnabled(!refreshing);
});
// 兑换配对码成功 -> 提示并刷新设备列表(observe 已自动触发列表刷新)
viewModel.getRedeemResult().observe(this, bindingItem -> {
if (bindingItem == null) return;
String name = bindingItem.getAlias();
if (name == null || name.isEmpty()) name = bindingItem.getDeviceUid();
Toast.makeText(this, "已成功绑定设备:" + name, Toast.LENGTH_LONG).show();
loadDevices();
});
// 兑换配对码失败 -> 提示
viewModel.getRedeemError().observe(this, err -> {
if (err != null && !err.isEmpty()) {
Toast.makeText(this, err, Toast.LENGTH_LONG).show();
}
});
// 进入主界面即拉取本机信息 + 已绑定设备 + 在线设备
loadDevices();
}
/** 合并设备列表(在线设备优先置于顶部),用于列表展示。 */
private void mergeDevices(List<BindingItem> items) {
for (BindingItem item : items) {
int idx = -1;
for (int i = 0; i < deviceList.size(); i++) {
if (deviceList.get(i).getDeviceUid().equals(item.getDeviceUid())) {
idx = i;
break;
}
}
if (idx >= 0) {
deviceList.set(idx, item);
} else {
deviceList.add(item);
}
}
// 在线设备排在前面
deviceList.sort((a, b) -> Boolean.compare(b.isOnline(), a.isOnline()));
}
/** 设备列表为空时显示占位提示。 */
private void updateDeviceEmpty() {
boolean empty = deviceList.isEmpty();
binding.tvDeviceEmpty.setVisibility(empty ? View.VISIBLE : View.GONE);
binding.listDevices.setVisibility(empty ? View.GONE : View.VISIBLE);
}
private void adjustVideoSize(int videoWidth, int videoHeight, int rotation) {
@@ -311,13 +435,36 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
});
}
/**
* 弹出输入配对码对话框,兑换后建立与被控端的绑定关系。
* 成功后由 MainViewModel 自动刷新设备列表,并在 getRedeemResult 中提示。
*/
private void showAddDeviceDialog() {
final EditText input = new EditText(this);
input.setInputType(android.text.InputType.TYPE_CLASS_TEXT);
input.setHint("请输入被控端显示的配对码");
new AlertDialog.Builder(this)
.setTitle("添加设备(配对码)")
.setMessage("在被控端主界面点击「生成配对码」获取 6 位配对码,有效期内输入即可绑定。")
.setView(input)
.setPositiveButton("绑定", (d, w) -> {
String code = input.getText().toString().trim();
if (code.isEmpty()) {
Toast.makeText(this, "配对码不能为空", Toast.LENGTH_SHORT).show();
return;
}
viewModel.redeemPairingCode(code);
})
.setNegativeButton("取消", null)
.show();
}
/**
* 弹出鉴权输入对话框:选择免密连接、动态验证码或固定密码,输入后发起连接。
*/
private void showAuthDialog() {
String target = binding.etTargetDeviceId.getText().toString().trim();
if (target.isEmpty()) {
Toast.makeText(this, "请先填写目标设备ID", Toast.LENGTH_SHORT).show();
if (targetDeviceId == null || targetDeviceId.isEmpty()) {
Toast.makeText(this, "请先在列表中选择一个已绑定设备", Toast.LENGTH_SHORT).show();
return;
}
@@ -381,10 +528,8 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
* 发起连接:交由 ViewModel 校验 / 刷新令牌,成功后经 LiveData 回调建立 WebSocket。
*/
private void connectToControlled() {
targetDeviceId = binding.etTargetDeviceId.getText().toString().trim();
if (targetDeviceId.isEmpty()) {
Toast.makeText(this, "请填写目标设备ID", Toast.LENGTH_SHORT).show();
if (targetDeviceId == null || targetDeviceId.isEmpty()) {
Toast.makeText(this, "请先在列表中选择一个已绑定设备", Toast.LENGTH_SHORT).show();
return;
}
@@ -399,12 +544,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
public void onRegistered(String fromDeviceId) {
// 服务端下发本机 deviceId(CONTROLLER),用于 WebRTC Offer 标识。
myDeviceId = fromDeviceId;
binding.etDeviceId.setText(fromDeviceId);
binding.tvStatus.setText("状态: 已注册 (" + fromDeviceId + "),正在发起连接...");
// 注册成功后初始化 WebRTC 并创建 Offer(myDeviceId 此时已就绪)。
initWebRtcAndConnect();
// 拉取可连接的被控端绑定列表(仅已绑定设备)
loadBindings();
// 拉取本机信息 + 可连接的被控端绑定列表(已绑定 + 在线设备)
loadDevices();
}
@Override
@@ -454,6 +597,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
* 退出登录并重置 UI 状态(清空令牌、停止连接)。
*/
private void logoutAndReset() {
connectTimeoutHandler.removeCallbacks(connectTimeoutRunnable);
if (wsClient != null) wsClient.disconnect();
wsClient = null;
if (webRtcClient != null) {
@@ -463,7 +607,6 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
stopHeartbeat();
viewModel.logout();
myDeviceId = null;
binding.etDeviceId.setText("");
binding.tvStatus.setText("状态: 已退出登录");
Intent intent = new Intent(this, LoginActivity.class);
@@ -488,10 +631,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
}
/**
* 拉取本机可连接的被控端(仅已绑定设备),用于辅助用户选择目标。
* 拉取本机信息 + 可连接的被控端(已绑定设备 + 当前在线设备),用于辅助用户选择目标。
*/
private void loadBindings() {
viewModel.loadBindings();
private void loadDevices() {
viewModel.refreshAll();
}
private void initWebRtcAndConnect() {
@@ -511,6 +654,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
@Override
public void onConnectionEstablished() {
connectionStartTime = System.currentTimeMillis();
connectTimeoutHandler.removeCallbacks(connectTimeoutRunnable);
runOnUiThread(() -> {
binding.tvStatus.setText("状态: 已连接 - 远程控制中");
updateUI(true);
@@ -535,6 +679,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
});
}
});
// 发起连接前显示「等待对方接受」中间态,并启动超时保护
binding.tvStatus.setText("状态: 已发送连接请求,等待对方接受...");
connectTimeoutHandler.removeCallbacks(connectTimeoutRunnable);
connectTimeoutHandler.postDelayed(connectTimeoutRunnable, CONNECT_WAIT_TIMEOUT_MS);
webRtcClient.createOffer(targetDeviceId, binding.remoteVideoView, pendingAuthType, pendingAuthValue);
}
@@ -563,6 +711,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
}
private void handleTargetOffline(SignalMessage message) {
connectTimeoutHandler.removeCallbacks(connectTimeoutRunnable);
String offlineId = message.getToDeviceId();
String payload = message.getPayload();
String text = (payload != null && !payload.isEmpty())
@@ -581,6 +730,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
* 被控端拒绝了连接请求:提示用户并复位到未连接状态。
*/
private void handleConnectionRejected(SignalMessage message) {
connectTimeoutHandler.removeCallbacks(connectTimeoutRunnable);
String targetId = message.getToDeviceId();
String reason = parseReason(message.getPayload());
String finalText = reason;
@@ -613,6 +763,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
* 连接请求被服务器拒绝(REQUEST_ERROR)或应答超时(REQUEST_TIMEOUT):提示并复位。
*/
private void handleRequestAborted(SignalMessage message) {
connectTimeoutHandler.removeCallbacks(connectTimeoutRunnable);
String payload = message.getPayload();
String text = (payload != null && !payload.isEmpty())
? payload
@@ -631,7 +782,8 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
String sdp = payload.get("sdp").getAsString();
if (webRtcClient != null) {
// 被控端已接受连接请求,进入 WebRTC 协商阶段。
// 被控端已接受连接请求,进入 WebRTC 协商阶段,取消等待超时保护。
connectTimeoutHandler.removeCallbacks(connectTimeoutRunnable);
runOnUiThread(() -> binding.tvStatus.setText("状态: 被控端已接受连接,正在建立连接..."));
webRtcClient.handleAnswer(sdp);
}
@@ -717,6 +869,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
private void disconnect() {
stopHeartbeat();
connectTimeoutHandler.removeCallbacks(connectTimeoutRunnable);
if (webRtcClient != null) {
webRtcClient.close();
webRtcClient = null;
@@ -850,7 +1003,6 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
private void updateUI(boolean connected) {
binding.setupPanel.setVisibility(connected ? View.GONE : View.VISIBLE);
binding.controlPanel.setVisibility(connected ? View.VISIBLE : View.GONE);
binding.btnConnect.setEnabled(!connected);
binding.btnDisconnect.setEnabled(connected);
binding.spinnerResolution.setEnabled(connected);
binding.spinnerFps.setEnabled(connected);
@@ -1320,9 +1472,61 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
@Override
protected void onDestroy() {
super.onDestroy();
UnauthorizedInterceptor.setHandler(null);
disconnect();
if (eglBase != null) {
eglBase.release();
}
}
/**
* 已绑定设备列表适配器:展示设备名称、在线/离线状态、设备ID 与绑定角色。
* 在线状态由 {@link #onlineDeviceSet} 标记。
*/
private class DeviceListAdapter extends BaseAdapter {
private final List<BindingItem> items;
DeviceListAdapter(List<BindingItem> items) {
this.items = items;
}
@Override
public int getCount() {
return items.size();
}
@Override
public Object getItem(int position) {
return items.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, android.view.ViewGroup parent) {
if (convertView == null) {
convertView = getLayoutInflater().inflate(R.layout.item_device, parent, false);
}
BindingItem item = items.get(position);
TextView tvName = convertView.findViewById(R.id.tv_device_name);
TextView tvStatus = convertView.findViewById(R.id.tv_device_status);
TextView tvUid = convertView.findViewById(R.id.tv_device_uid);
String name = item.getAlias();
if (name == null || name.isEmpty()) name = item.getDeviceUid();
tvName.setText(name);
boolean online = onlineDeviceSet.contains(item.getDeviceUid());
tvStatus.setText(online ? getString(R.string.device_online) : getString(R.string.device_offline));
tvStatus.setTextColor(online ? 0xFF2E7D32 : 0xFF9E9E9E);
String role = "OWNER".equals(item.getRole()) ? getString(R.string.device_owner) : getString(R.string.device_member);
tvUid.setText(item.getDeviceUid() + " · " + role);
return convertView;
}
}
}

View File

@@ -6,6 +6,8 @@ import androidx.lifecycle.MutableLiveData;
import com.ttstd.controller.base.mvvm.BaseViewModel;
import com.ttstd.controller.network.AuthRepository;
import com.ttstd.controller.network.model.BindingItem;
import com.ttstd.controller.network.model.OnlineDevicesResponse;
import com.ttstd.controller.network.model.VerifyResponse;
import com.ttstd.controller.utils.TokenStore;
import java.util.List;
@@ -36,6 +38,27 @@ public class MainViewModel extends BaseViewModel {
/** 已绑定设备列表。 */
private final MutableLiveData<List<BindingItem>> bindings = new MutableLiveData<>();
/** 当前已绑定且在线(可直接连接)的设备列表。 */
private final MutableLiveData<List<BindingItem>> onlineDevices = new MutableLiveData<>();
/** 设备列表加载失败提示。 */
private final MutableLiveData<String> deviceError = new MutableLiveData<>();
/** 兑换配对码成功后的绑定结果(用于界面反馈)。 */
private final MutableLiveData<BindingItem> redeemResult = new MutableLiveData<>();
/** 兑换配对码失败提示。 */
private final MutableLiveData<String> redeemError = new MutableLiveData<>();
/** 本机账号信息(用户 id / 用户名),对应 {@code GET /api/client/verify}。 */
private final MutableLiveData<VerifyResponse> accountInfo = new MutableLiveData<>();
/** 本机信息加载失败提示。 */
private final MutableLiveData<String> accountError = new MutableLiveData<>();
/** 列表刷新中标记,供 UI 控制刷新按钮可用性。 */
private final MutableLiveData<Boolean> refreshing = new MutableLiveData<>(false);
public LiveData<String> getAccessToken() {
return accessToken;
}
@@ -52,6 +75,34 @@ public class MainViewModel extends BaseViewModel {
return bindings;
}
public LiveData<List<BindingItem>> getOnlineDevices() {
return onlineDevices;
}
public LiveData<String> getDeviceError() {
return deviceError;
}
public LiveData<BindingItem> getRedeemResult() {
return redeemResult;
}
public LiveData<String> getRedeemError() {
return redeemError;
}
public LiveData<VerifyResponse> getAccountInfo() {
return accountInfo;
}
public LiveData<String> getAccountError() {
return accountError;
}
public LiveData<Boolean> getRefreshing() {
return refreshing;
}
/**
* 确保 accessToken 有效:先校验,失效则自动刷新,结果通过
* {@link #getAccessToken()} 回调到主线程。
@@ -93,8 +144,44 @@ public class MainViewModel extends BaseViewModel {
execute(
repository.bindings(),
response -> bindings.setValue(response.getBindings()),
error -> deviceError.setValue("绑定设备加载失败:" + error.getMessage()));
}
/** 拉取当前用户已绑定且在线(信令网络活跃)的被控端设备列表。 */
public void loadOnlineDevices() {
execute(
repository.onlineDevices(),
response -> onlineDevices.setValue(response.getDevices()),
error -> deviceError.setValue("在线设备加载失败:" + error.getMessage()));
}
/** 拉取本机账号信息(用户 id / 用户名)。 */
public void loadAccountInfo() {
execute(
repository.verify(),
resp -> accountInfo.setValue(resp),
error -> accountError.setValue("本机信息加载失败:" + error.getMessage()));
}
/**
* 统一刷新:本机信息 + 已绑定设备 + 在线设备,供刷新按钮调用。
* 通过 {@link #getRefreshing()} 暴露刷新中状态。
*/
public void refreshAll() {
refreshing.setValue(true);
execute(
repository.verify()
.doOnSuccess(accountInfo::setValue)
.flatMap(v -> repository.bindings())
.doOnSuccess(resp -> bindings.setValue(resp.getBindings()))
.flatMap(v -> repository.onlineDevices()),
response -> {
onlineDevices.setValue(response.getDevices());
refreshing.setValue(false);
},
error -> {
// 列表拉取失败不阻断主流程,保持静默
deviceError.setValue("刷新失败:" + error.getMessage());
refreshing.setValue(false);
});
}
@@ -102,4 +189,17 @@ public class MainViewModel extends BaseViewModel {
public void logout() {
repository.logout();
}
/** 兑换配对码,建立与被控端的绑定关系;成功后刷新设备列表。 */
public void redeemPairingCode(String code) {
execute(
repository.redeemPairingCode(code),
binding -> {
redeemResult.setValue(binding);
// 绑定成功后刷新可连设备列表
loadBindings();
loadOnlineDevices();
},
error -> redeemError.setValue("绑定失败:" + error.getMessage()));
}
}

View File

@@ -1,12 +1,16 @@
package com.ttstd.controller.network;
import com.ttstd.controller.network.model.BindingItem;
import com.ttstd.controller.network.model.BindingsResponse;
import com.ttstd.controller.network.model.OnlineDevicesResponse;
import com.ttstd.controller.network.model.LoginRequest;
import com.ttstd.controller.network.model.RedeemRequest;
import com.ttstd.controller.network.model.RefreshRequest;
import com.ttstd.controller.network.model.TokenResponse;
import com.ttstd.controller.network.model.VerifyResponse;
import com.ttstd.controller.utils.TokenStore;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -35,7 +39,8 @@ public class AuthRepository {
token.getAccessToken(),
token.getRefreshToken(),
username))
.subscribeOn(Schedulers.io());
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/**
@@ -51,7 +56,8 @@ public class AuthRepository {
TokenStore.get().saveAccessToken(token.getAccessToken());
TokenStore.get().saveRefreshToken(token.getRefreshToken());
})
.subscribeOn(Schedulers.io());
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/**
@@ -69,12 +75,32 @@ public class AuthRepository {
.flatMap(token -> clientApi.verify())
.map(VerifyResponse::isValid)
.onErrorReturnItem(false))
.subscribeOn(Schedulers.io());
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/** 校验当前令牌并返回本机账号信息(用户 id / 用户名)。 */
public Single<VerifyResponse> verify() {
return clientApi.verify().subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/** 查询当前用户已绑定的被控端设备列表。 */
public Single<BindingsResponse> bindings() {
return clientApi.bindings().subscribeOn(Schedulers.io());
return clientApi.bindings().subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/** 查询当前用户已绑定且在线(信令网络活跃)的被控端设备列表。 */
public Single<OnlineDevicesResponse> onlineDevices() {
return clientApi.onlineDevices().subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/** 兑换配对码,建立与被控端的绑定关系。 */
public Single<BindingItem> redeemPairingCode(String code) {
return clientApi.redeemPairingCode(new RedeemRequest(code)).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/** 退出登录:清空本地令牌。 */

View File

@@ -1,10 +1,15 @@
package com.ttstd.controller.network;
import com.ttstd.controller.network.model.BindingItem;
import com.ttstd.controller.network.model.BindingsResponse;
import com.ttstd.controller.network.model.OnlineDevicesResponse;
import com.ttstd.controller.network.model.RedeemRequest;
import com.ttstd.controller.network.model.VerifyResponse;
import io.reactivex.rxjava3.core.Single;
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST;
/**
* 用户端业务接口,对应服务端 {@code /api/client}。
@@ -17,7 +22,15 @@ public interface ClientApi {
@GET("api/client/verify")
Single<VerifyResponse> verify();
/** 查询当前用户已绑定的被控端设备列表。 */
/** 查询当前用户已绑定的被控端设备列表(含 online 在线标记)。 */
@GET("api/client/bindings")
Single<BindingsResponse> bindings();
/** 查询当前用户已绑定且在线(信令网络活跃)的被控端设备列表。 */
@GET("api/client/devices/online")
Single<OnlineDevicesResponse> onlineDevices();
/** 兑换配对码,建立与被控端的绑定关系。返回新建的绑定记录(结构同 BindingItem)。 */
@POST("api/client/pairing/redeem")
Single<BindingItem> redeemPairingCode(@Body RedeemRequest request);
}

View File

@@ -49,6 +49,7 @@ public final class RetrofitClient {
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.addInterceptor(new AuthInterceptor())
.addInterceptor(new UnauthorizedInterceptor())
.addInterceptor(logging)
.build();

View File

@@ -0,0 +1,63 @@
package com.ttstd.controller.network;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.ttstd.controller.utils.TokenStore;
import java.io.IOException;
import okhttp3.Interceptor;
import okhttp3.Response;
/**
* 统一 401 拦截器。
*
* <p>任意 REST 接口返回 401(未授权 / 令牌失效)时,清理本地令牌并通过注册的
* {@link OnUnauthorizedListener} 通知 UI 回到登录页。这样 {@code /bindings}、
* {@code /devices/online-guests} 等普通业务接口的 401 也能与 WebSocket 4001/4003 走一致的
* 「回登录页」逻辑,无需在每个 ViewModel 回调里重复判断。
*
* <p>因 OkHttp 为全局单例,可能在没有 Activity 监听时收到 401,故用静态 {@code handler}:
* 由 {@code MainActivity} 在 {@code onCreate} 注册、{@code onDestroy} 注销。未注册时仅清令牌。
*/
public class UnauthorizedInterceptor implements Interceptor {
/** 401 回调:通常触发「清理 UI 状态 + 跳登录页」。 */
public interface OnUnauthorizedListener {
void onUnauthorized(String message);
}
@Nullable
private static volatile OnUnauthorizedListener handler;
/** 由前台 Activity 注册;传 null 注销。 */
public static void setHandler(@Nullable OnUnauthorizedListener listener) {
handler = listener;
}
private static final String TAG = "UnauthorizedInterceptor";
private static volatile boolean notified = false;
@NonNull
@Override
public Response intercept(@NonNull Chain chain) throws IOException {
Response response = chain.proceed(chain.request());
if (response.code() == 401) {
Log.w(TAG, "收到 401 未授权响应,清理令牌并回到登录页");
// 仅触发一次,避免并发请求同时跳转多次。
if (!notified) {
notified = true;
TokenStore.get().clear();
OnUnauthorizedListener current = handler;
if (current != null) {
current.onUnauthorized("登录已失效,请重新登录");
}
notified = false;
}
}
return response;
}
}

View File

@@ -26,6 +26,10 @@ public class BindingItem {
@SerializedName("status")
private String status;
/** 被控端当前是否在线(信令网络中存在活跃会话)。 */
@SerializedName("online")
private boolean online;
public String getBindingId() {
return bindingId;
}
@@ -50,6 +54,10 @@ public class BindingItem {
return status;
}
public boolean isOnline() {
return online;
}
/** 下拉展示文案:有别名时优先展示别名。 */
public String displayName() {
if (alias != null && !alias.trim().isEmpty()) {

View File

@@ -0,0 +1,23 @@
package com.ttstd.controller.network.model;
import com.google.gson.annotations.SerializedName;
import java.util.List;
/**
* 当前已绑定且在线(信令网络中活跃)的被控端设备列表响应。
* 与 {@link BindingsResponse} 中单条设备结构兼容。
*/
public class OnlineDevicesResponse {
@SerializedName("devices")
private List<BindingItem> devices;
public List<BindingItem> getDevices() {
return devices;
}
public void setDevices(List<BindingItem> devices) {
this.devices = devices;
}
}

View File

@@ -0,0 +1,25 @@
package com.ttstd.controller.network.model;
import com.google.gson.annotations.SerializedName;
/**
* 兑换配对码请求体,对应服务端 {@code POST /api/client/pairing/redeem}。
*/
public class RedeemRequest {
/** 被控端生成的一次性配对码。 */
@SerializedName("code")
private String code;
public RedeemRequest(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
}

View File

@@ -20,50 +20,94 @@
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="本机设备ID:"
android:textSize="14sp" />
<EditText
android:id="@+id/et_device_id"
<!-- 本机信息 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="设备ID"
android:inputType="text" />
android:layout_marginBottom="8dp"
android:background="#EEEEEE"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="目标被控设备ID:"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="本机信息"
android:textSize="14sp"
android:textStyle="bold" />
<EditText
android:id="@+id/et_target_device_id"
<TextView
android:id="@+id/tv_account_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="用户ID: 加载中…"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_account_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="用户名: -"
android:textSize="14sp" />
</LinearLayout>
<!-- 已绑定设备列表标题栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:hint="被控端设备ID"
android:inputType="text"
android:text="981964879" />
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="已绑定设备(点击连接)"
android:textSize="14sp" />
<Button
android:id="@+id/btn_refresh"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/btn_refresh" />
</LinearLayout>
<ListView
android:id="@+id/list_devices"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/tv_device_empty"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:text="暂无已绑定设备,请点击「添加设备」"
android:textSize="14sp"
android:visibility="gone" />
<TextView
android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp"
android:text="状态: 已停止"
android:textSize="16sp"
android:textStyle="bold" />
<Button
android:id="@+id/btn_connect"
android:id="@+id/btn_add_device"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="连接被控设备" />
android:text="@string/btn_add_device" />
<Button
android:id="@+id/btn_disconnect"

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tv_device_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="设备"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_device_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="在线"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/tv_device_uid"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="dev_xxx"
android:textSize="12sp"
android:textColor="#666666" />
</LinearLayout>

View File

@@ -29,4 +29,10 @@
<string name="login_in_progress">登录中…</string>
<string name="login_success">登录成功</string>
<string name="logout">退出登录</string>
<string name="btn_add_device">添加设备(配对码)</string>
<string name="btn_refresh">刷新</string>
<string name="device_online">在线</string>
<string name="device_offline">离线</string>
<string name="device_owner">所有者</string>
<string name="device_member">成员</string>
</resources>

View File

@@ -447,6 +447,25 @@ public class AdminController {
"total", deviceIdentityService.getAllowlist().size());
}
// ==================== 用户绑定关系 ====================
/** 列出某用户账号绑定的全部终端设备(含已撤销),并附在线状态。 */
@GetMapping("/users/{userId}/bindings")
public List<Map<String, Object>> userBindings(@PathVariable String userId) {
return bindingService.listByUser(userId).stream().map(b -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("bindingId", b.getBindingId());
item.put("deviceUid", b.getDeviceUid());
item.put("userId", b.getUserId());
item.put("role", b.getRole().name());
item.put("alias", b.getAlias());
item.put("status", b.getStatus().name());
item.put("online", sessionManager.isDeviceOnline(b.getDeviceUid()));
item.put("boundAt", b.getBoundAt() == null ? null : b.getBoundAt().toEpochMilli());
return item;
}).toList();
}
// ==================== 绑定 / 黑名单管理 ====================
/** 列出某设备的全部绑定关系(含已撤销)。 */

View File

@@ -1,6 +1,7 @@
package com.ttstd.signaling.controller;
import com.ttstd.signaling.config.BearerAuthFilter;
import com.ttstd.signaling.manager.SessionManager;
import com.ttstd.signaling.model.AccountStatus;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.model.DeviceAccount;
@@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -50,6 +52,7 @@ public class ClientController {
private final PairingService pairingService;
private final TurnCredentialService turnCredentialService;
private final AbuseReportService abuseReportService;
private final SessionManager sessionManager;
public ClientController(JwtService jwtService,
SecurityProperties properties,
@@ -57,7 +60,8 @@ public class ClientController {
BindingService bindingService,
PairingService pairingService,
TurnCredentialService turnCredentialService,
AbuseReportService abuseReportService) {
AbuseReportService abuseReportService,
SessionManager sessionManager) {
this.jwtService = jwtService;
this.properties = properties;
this.deviceIdentityService = deviceIdentityService;
@@ -65,6 +69,7 @@ public class ClientController {
this.pairingService = pairingService;
this.turnCredentialService = turnCredentialService;
this.abuseReportService = abuseReportService;
this.sessionManager = sessionManager;
}
// ==================== 令牌校验 ====================
@@ -336,6 +341,32 @@ public class ClientController {
return r;
}
/**
* 主控端查看当前「已绑定且在线」的设备列表,便于快速发起连接。
* 仅返回当前在信令网络中有活跃 WebSocket 会话的被控端。
*/
@GetMapping("/devices/online")
public Map<String, Object> onlineDevices(HttpServletRequest request) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isUser()) {
throw AuthException.forbidden("仅用户令牌可调用");
}
List<Map<String, Object>> online = bindingService.listByUser(principal.principalId()).stream()
.filter(b -> b.getStatus() == DeviceBinding.BindingStatus.ACTIVE)
.filter(b -> sessionManager.isDeviceOnline(b.getDeviceUid()))
.map(b -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("deviceUid", b.getDeviceUid());
m.put("alias", b.getAlias());
m.put("online", true);
return m;
})
.toList();
Map<String, Object> r = new LinkedHashMap<>();
r.put("devices", online);
return r;
}
private Map<String, Object> bindingView(DeviceBinding b) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("bindingId", b.getBindingId());
@@ -344,6 +375,7 @@ public class ClientController {
m.put("role", b.getRole().name());
m.put("alias", b.getAlias());
m.put("status", b.getStatus().name());
m.put("online", sessionManager.isDeviceOnline(b.getDeviceUid()));
return m;
}

View File

@@ -140,6 +140,8 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
Map<String, Object> response = new HashMap<>();
response.put("type", "REGISTER_SUCCESS");
response.put("deviceId", principal.deviceId());
// 与既有信令消息协议一致:下发的设备ID也写入 fromDeviceId,便于客户端统一解析。
response.put("fromDeviceId", principal.deviceId());
response.put("deviceType", principal.deviceType().name());
response.put("displayName", principal.displayName());
sendToSession(session, response);
@@ -286,6 +288,7 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
Map<String, Object> response = new HashMap<>();
response.put("type", "REGISTER_SUCCESS");
response.put("deviceId", principal.deviceId());
response.put("fromDeviceId", principal.deviceId());
response.put("deviceType", principal.deviceType().name());
response.put("displayName", principal.displayName());
sendToSession(session, response);

View File

@@ -298,3 +298,26 @@ export function removeAllowlist(sn: string): Promise<{ success: boolean; total:
`/api/admin/device-allowlist/${encodeURIComponent(sn)}`,
) as unknown as Promise<{ success: boolean; total: number }>;
}
// ==================== 绑定关系 ====================
export type BindingRole = 'OWNER' | 'MEMBER';
export type BindingStatus = 'ACTIVE' | 'REVOKED';
export interface UserBindingView {
bindingId: string;
deviceUid: string;
userId: string;
role: BindingRole;
alias: string | null;
status: BindingStatus;
online: boolean;
boundAt: number | null;
}
/** 获取某用户绑定的全部终端设备(含在线状态)。 */
export function getUserBindings(userId: string): Promise<UserBindingView[]> {
return request.get(
`/api/admin/users/${encodeURIComponent(userId)}/bindings`,
) as unknown as Promise<UserBindingView[]>;
}

View File

@@ -2,8 +2,10 @@ import axios from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8080';
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
baseURL: BASE_URL,
timeout: 15000,
});
@@ -15,12 +17,34 @@ request.interceptors.request.use((config) => {
return config;
});
/**
* 统一响应拦截:除 401 外所有异常都给出错误提示。
* 401 表示令牌失效/未登录,清除本地令牌并跳转登录页。
*/
request.interceptors.response.use(
(resp) => resp.data,
(error) => {
const status = error.response?.status;
if (status === 401) {
localStorage.removeItem('admin_token');
ElMessage.error('登录已失效,请重新登录');
if (router.currentRoute.value.name !== 'Login') {
router.replace({ name: 'Login' });
}
} else {
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
}
return Promise.reject(error);
},
);
/**
* 不携带任何鉴权头的请求实例,专用于登录等公开接口,
* 避免把本地可能过期/失效的令牌误带上去造成 401 / 鉴权混乱。
* 公开接口的 401 表示账号或密码错误,直接提示,不跳转(已在登录页)。
*/
export const publicRequest = axios.create({
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
baseURL: BASE_URL,
timeout: 15000,
});
@@ -29,7 +53,6 @@ publicRequest.interceptors.response.use(
(error) => {
const status = error.response?.status;
if (status === 401) {
localStorage.removeItem('admin_token');
ElMessage.error('登录失败,请检查账号或密码');
} else {
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
@@ -40,19 +63,3 @@ publicRequest.interceptors.response.use(
export default request;
request.interceptors.response.use(
(resp) => resp.data,
(error) => {
const status = error.response?.status;
if (status === 401) {
localStorage.removeItem('admin_token');
ElMessage.error('登录已失效,请重新登录');
router.replace({ name: 'Login' });
} else {
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
}
return Promise.reject(error);
},
);

View File

@@ -30,10 +30,13 @@
<el-table v-loading="loading" :data="users" stripe>
<el-table-column prop="username" label="用户名" min-width="150">
<template #default="{ row }">
<span>{{ row.username }}</span>
<span class="link-user" @click="openBindings(row)">{{ row.username }}</span>
<el-tag v-if="row.admin" type="danger" size="small" style="margin-left: 6px">
管理员
</el-tag>
<el-tag v-if="row.deviceCount > 0" type="info" size="small" style="margin-left: 6px">
{{ row.deviceCount }} 台设备
</el-tag>
</template>
</el-table-column>
<el-table-column prop="userId" label="用户 ID" min-width="200" show-overflow-tooltip />
@@ -90,6 +93,7 @@
<el-button link type="info">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="bindings">查看绑定设备</el-dropdown-item>
<el-dropdown-item command="sessions">查看会话</el-dropdown-item>
<el-dropdown-item command="reset">重置密码</el-dropdown-item>
<el-dropdown-item command="admin">
@@ -160,6 +164,40 @@
</el-table-column>
</el-table>
</el-dialog>
<!-- 绑定关系(点击用户名查看该用户绑定的终端设备) -->
<el-dialog v-model="bindingsVisible" :title="`「${bindingUser?.username}」绑定的终端设备`" width="760px">
<el-table v-loading="bindingLoading" :data="userBindings" stripe size="small" empty-text="该用户暂未绑定任何终端设备">
<el-table-column prop="deviceUid" label="设备 ID" min-width="200" show-overflow-tooltip />
<el-table-column label="别名" min-width="140">
<template #default="{ row }">{{ row.alias || '—' }}</template>
</el-table-column>
<el-table-column label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'OWNER' ? 'primary' : 'info'" size="small">
{{ row.role === 'OWNER' ? '所有者' : '成员' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'danger'" size="small">
{{ row.status === 'ACTIVE' ? '有效' : '已撤销' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="在线" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.online ? 'success' : 'info'" size="small" effect="dark">
{{ row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定时间" min-width="170">
<template #default="{ row }">{{ fmtTime(row.boundAt) }}</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
@@ -178,9 +216,10 @@ import {
unbanUser,
kickUser,
getUserSessions,
getUserBindings,
kickSession,
} from '@/api/admin';
import type { AccountStatus, UserAccountView, UserSessionView } from '@/api/admin';
import type { AccountStatus, UserAccountView, UserSessionView, UserBindingView } from '@/api/admin';
const users = ref<UserAccountView[]>([]);
const loading = ref(false);
@@ -348,6 +387,26 @@ async function openSessions(row: UserAccountView) {
}
}
// ---------- 绑定关系(点击用户名查看该用户绑定的终端设备) ----------
const bindingsVisible = ref(false);
const bindingUser = ref<UserAccountView | null>(null);
const userBindings = ref<UserBindingView[]>([]);
const bindingLoading = ref(false);
async function openBindings(row: UserAccountView) {
bindingUser.value = row;
bindingsVisible.value = true;
bindingLoading.value = true;
try {
userBindings.value = await getUserBindings(row.userId);
} catch {
userBindings.value = [];
ElMessage.error('加载绑定关系失败');
} finally {
bindingLoading.value = false;
}
}
async function handleKickSession(row: UserSessionView) {
await kickSession(row.sessionId);
ElMessage.success('会话已踢出');
@@ -356,7 +415,8 @@ async function handleKickSession(row: UserSessionView) {
}
function handleCommand(cmd: string, row: UserAccountView) {
if (cmd === 'sessions') openSessions(row);
if (cmd === 'bindings') openBindings(row);
else if (cmd === 'sessions') openSessions(row);
else if (cmd === 'reset') openReset(row);
else if (cmd === 'admin') handleToggleAdmin(row);
else if (cmd === 'delete') handleDelete(row);
@@ -383,6 +443,14 @@ onMounted(load);
align-items: center;
gap: 12px;
}
.link-user {
color: var(--el-color-primary);
cursor: pointer;
font-weight: 500;
}
.link-user:hover {
text-decoration: underline;
}
.page-title {
margin: 0 0 4px;
font-size: 20px;