feat(controlled, controller): 新增配对码生成与兑换功能
被控端:新增生成一次性配对码并弹窗展示,支持倒计时与复制;控制端:新增输入配对码兑换绑定,刷新设备列表,并在设备列表中显示在线状态和点击连接。同时添加统一 401 拦截器,令牌失效时触发重新激活。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/** 退出登录:清空本地令牌。 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ public final class RetrofitClient {
|
||||
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.addInterceptor(new AuthInterceptor())
|
||||
.addInterceptor(new UnauthorizedInterceptor())
|
||||
.addInterceptor(logging)
|
||||
.build();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
44
WebRTCController/app/src/main/res/layout/item_device.xml
Normal file
44
WebRTCController/app/src/main/res/layout/item_device.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// ==================== 绑定 / 黑名单管理 ====================
|
||||
|
||||
/** 列出某设备的全部绑定关系(含已撤销)。 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user