feat(controlled, controller): 新增配对码生成与兑换功能
被控端:新增生成一次性配对码并弹窗展示,支持倒计时与复制;控制端:新增输入配对码兑换绑定,刷新设备列表,并在设备列表中显示在线状态和点击连接。同时添加统一 401 拦截器,令牌失效时触发重新激活。
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user