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

@@ -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) {
// 服务端下发本机 deviceIdCONTROLLER用于 WebRTC Offer 标识。
myDeviceId = fromDeviceId;
binding.etDeviceId.setText(fromDeviceId);
binding.tvStatus.setText("状态: 已注册 (" + fromDeviceId + "),正在发起连接...");
// 注册成功后初始化 WebRTC 并创建 OffermyDeviceId 此时已就绪)。
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>