feat: 增加密码连接

This commit is contained in:
2026-07-22 20:32:52 +08:00
parent ef786b1529
commit bdde6ccd2e
19 changed files with 541 additions and 25 deletions

View File

@@ -32,6 +32,7 @@ import com.ttstd.controlled.base.BaseMvvmActivity;
import com.ttstd.controlled.databinding.ActivityMainBinding;
import com.ttstd.controlled.service.ScreenCaptureService;
import com.ttstd.controlled.accessibility.KeyboardAccessibilityService;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.utils.DeviceUtils;
import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.webrtc.WebRtcClient;
@@ -88,6 +89,31 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.btnStart.setOnClickListener(v -> startScreenSharing());
binding.btnStop.setOnClickListener(v -> stopScreenSharing());
// 安全验证:显示动态验证码,提供重新生成与固定密码设置
binding.tvDynamicCode.setText(AuthSettings.getDynamicCode(this));
binding.btnRegenerateCode.setOnClickListener(v -> {
String newCode = AuthSettings.generateDynamicCode();
AuthSettings.setDynamicCode(this, newCode);
binding.tvDynamicCode.setText(newCode);
binding.tvAuthHint.setText("动态验证码已重新生成");
});
binding.btnSavePassword.setOnClickListener(v -> {
String p1 = binding.etFixedPassword.getText().toString();
String p2 = binding.etConfirmPassword.getText().toString();
if (p1.isEmpty()) {
binding.tvAuthHint.setText("固定密码不能为空");
return;
}
if (!p1.equals(p2)) {
binding.tvAuthHint.setText("两次输入的密码不一致");
return;
}
AuthSettings.setFixedPassword(this, p1);
binding.etFixedPassword.setText("");
binding.etConfirmPassword.setText("");
binding.tvAuthHint.setText("固定密码已保存");
});
updateUI(false);
}

View File

@@ -37,6 +37,7 @@ import com.ttstd.controlled.input.ShellInputUtils;
import com.ttstd.controlled.input.SystemInputUtils;
import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.signaling.SignalMessage;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.signaling.WebSocketClient;
import com.ttstd.controlled.webrtc.WebRtcClient;
@@ -457,18 +458,78 @@ public class ScreenCaptureService extends Service {
}
String controllerId = message.getFromDeviceId();
// 暂存请求,弹出确认对话框,交由用户决定是否接受。
// 暂存请求信息
pendingControllerId = controllerId;
pendingControllerName = (controllerName != null && !controllerName.isEmpty())
? controllerName : controllerId;
pendingOfferSdp = offerSdp;
// 鉴权:动态验证码(CODE) / 固定密码(PASSWORD)。
// 服务器仅做转发,真实校验在此完成(密钥不离开被控端)。
String authType = message.getAuthType();
String authValue = message.getAuthValue();
if (authType != null && !authType.trim().isEmpty()) {
String failReason = verifyAuth(authType.trim(), authValue);
if (failReason == null) {
// 校验通过:自动接受,直接建立连接(无需手动确认)
Log.i(TAG, "Auth passed (" + authType + "), auto-accepting " + controllerId);
mainHandler.post(this::acceptConnection);
} else {
Log.w(TAG, "Auth failed (" + authType + "): " + failReason + " from " + controllerId);
sendAuthRejected(failReason);
}
return;
}
// 未携带鉴权信息:沿用原有手动确认弹窗
Intent dialogIntent = new Intent(this, ConnectionRequestActivity.class);
dialogIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
dialogIntent.putExtra(ConnectionRequestActivity.EXTRA_CONTROLLER_ID, controllerId);
startActivity(dialogIntent);
}
/**
* 校验控制端发来的鉴权信息。返回 null 表示通过,否则返回失败原因。
* CODE: 与被控端当前动态验证码比对;
* PASSWORD: 与被控端固定密码比对。
*/
private String verifyAuth(String authType, String authValue) {
if (authValue == null) authValue = "";
if ("CODE".equalsIgnoreCase(authType)) {
String expected = AuthSettings.getDynamicCode(this);
if (expected == null || expected.isEmpty()) {
return "被控端未生成动态验证码";
}
return expected.equals(authValue) ? null : "动态验证码错误";
} else if ("PASSWORD".equalsIgnoreCase(authType)) {
String expected = AuthSettings.getFixedPassword(this);
if (expected == null || expected.isEmpty()) {
return "被控端未设置固定密码";
}
return expected.equals(authValue) ? null : "固定密码错误";
}
return "未知的鉴权类型: " + authType;
}
/** 鉴权失败时,向控制端回送带原因的拒绝消息并清理待处理状态。 */
private void sendAuthRejected(String reason) {
if (pendingControllerId == null || wsClient == null) {
return;
}
String controllerId = pendingControllerId;
clearPendingRequest();
SignalMessage msg = new SignalMessage();
msg.setType("CONNECTION_REJECTED");
msg.setFromDeviceId(deviceId);
msg.setToDeviceId(controllerId);
msg.setDeviceType("CONTROLLED");
JsonObject payload = new JsonObject();
payload.addProperty("reason", reason);
msg.setPayload(payload.toString());
wsClient.sendMessage(msg);
Log.i(TAG, "Sent CONNECTION_REJECTED(" + reason + ") to " + controllerId);
}
/**
* 用户在弹窗中点击“接受”:创建 Answer 完成连接。
*/

View File

@@ -7,6 +7,8 @@ public class SignalMessage {
private String toDeviceId;
private String deviceType;
private String payload;
private String authType;
private String authValue;
public SignalMessage() {}
@@ -31,4 +33,10 @@ public class SignalMessage {
public String getPayload() { return payload; }
public void setPayload(String payload) { this.payload = payload; }
public String getAuthType() { return authType; }
public void setAuthType(String authType) { this.authType = authType; }
public String getAuthValue() { return authValue; }
public void setAuthValue(String authValue) { this.authValue = authValue; }
}

View File

@@ -0,0 +1,60 @@
package com.ttstd.controlled.utils;
import android.content.Context;
import android.content.SharedPreferences;
import java.security.SecureRandom;
/**
* 被控端安全验证配置:动态验证码与固定密码。
* 密钥仅保存在被控端本机,服务器只做转发,不接触任何密钥。
*/
public class AuthSettings {
private static final String PREF_NAME = "controlled_auth_prefs";
private static final String KEY_DYNAMIC_CODE = "dynamic_code";
private static final String KEY_FIXED_PASSWORD = "fixed_password";
private static final String CODE_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final int CODE_LENGTH = 6;
private static final SecureRandom RANDOM = new SecureRandom();
/** 生成一个 6 位、由大小写字母与数字组成的随机验证码。 */
public static String generateDynamicCode() {
StringBuilder sb = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
/**
* 获取当前动态验证码;若不存在则随机生成并持久化。
*/
public static String getDynamicCode(Context context) {
SharedPreferences sp = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
String code = sp.getString(KEY_DYNAMIC_CODE, null);
if (code == null || code.isEmpty()) {
code = generateDynamicCode();
sp.edit().putString(KEY_DYNAMIC_CODE, code).apply();
}
return code;
}
public static void setDynamicCode(Context context, String code) {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_DYNAMIC_CODE, code == null ? "" : code).apply();
}
/** 获取固定密码(未设置时为空字符串)。 */
public static String getFixedPassword(Context context) {
return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getString(KEY_FIXED_PASSWORD, "");
}
public static void setFixedPassword(Context context, String password) {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_FIXED_PASSWORD, password == null ? "" : password).apply();
}
}

View File

@@ -61,6 +61,85 @@
android:textSize="16sp"
android:textStyle="bold" />
<!-- 安全验证:动态验证码 + 固定密码 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="6dp"
android:text="安全验证"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="动态验证码(连接时由控制端输入):"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<TextView
android:id="@+id/tv_dynamic_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="------"
android:textSize="22sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:letterSpacing="0.1" />
<Button
android:id="@+id/btn_regenerate_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="重新生成" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="固定密码(手动设置并确认):"
android:textSize="14sp" />
<EditText
android:id="@+id/et_fixed_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="输入固定密码"
android:inputType="textPassword" />
<EditText
android:id="@+id/et_confirm_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="再次输入固定密码"
android:inputType="textPassword" />
<Button
android:id="@+id/btn_save_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="保存固定密码" />
<TextView
android:id="@+id/tv_auth_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:textSize="13sp"
android:textColor="#888888" />
<Button
android:id="@+id/btn_start"
android:layout_width="match_parent"

View File

@@ -15,6 +15,8 @@ import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Spinner;
import android.widget.RadioGroup;
import androidx.appcompat.app.AlertDialog;
import java.util.ArrayList;
import java.util.List;
@@ -62,6 +64,8 @@ public class MainActivity extends AppCompatActivity {
private final Gson gson = new Gson();
private String myDeviceId;
private String targetDeviceId;
private String pendingAuthType;
private String pendingAuthValue;
private final Handler statsHandler = new Handler(Looper.getMainLooper());
private final Runnable statsRunnable = new Runnable() {
@@ -105,7 +109,7 @@ public class MainActivity extends AppCompatActivity {
myDeviceId = DeviceUtils.getSerialNumber(this);
etDeviceId.setText(myDeviceId);
btnConnect.setOnClickListener(v -> connectToControlled());
btnConnect.setOnClickListener(v -> showAuthDialog());
btnDisconnect.setOnClickListener(v -> disconnect());
// 初始化 EGL 和远端视频渲染
@@ -210,6 +214,56 @@ public class MainActivity extends AppCompatActivity {
});
}
/** 弹出鉴权输入对话框:选择动态验证码或固定密码,输入后发起连接。 */
private void showAuthDialog() {
String serverUrl = etServerUrl.getText().toString().trim();
String target = etTargetDeviceId.getText().toString().trim();
String myId = etDeviceId.getText().toString().trim();
if (serverUrl.isEmpty() || target.isEmpty() || myId.isEmpty()) {
Toast.makeText(this, "请先填写服务器地址、设备ID和目标设备ID", Toast.LENGTH_SHORT).show();
return;
}
View view = getLayoutInflater().inflate(R.layout.dialog_auth_input, null);
RadioGroup rg = view.findViewById(R.id.rg_auth_type);
EditText et = view.findViewById(R.id.et_auth_value);
TextView tip = view.findViewById(R.id.tv_auth_tip);
rg.setOnCheckedChangeListener((group, checkedId) -> {
if (checkedId == R.id.rb_password) {
et.setHint("请输入固定密码");
tip.setText("固定密码为被控端设置的固定密码");
} else {
et.setHint("请输入动态验证码");
tip.setText("动态验证码为被控端界面显示的 6 位大小写字母与数字组合");
}
});
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("连接鉴权")
.setView(view)
.setNegativeButton("取消", null)
.setPositiveButton("连接", null)
.create();
// 校验失败时不关闭对话框,引导用户重新输入
dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE)
.setOnClickListener(v -> {
boolean isPassword = (rg.getCheckedRadioButtonId() == R.id.rb_password);
String authType = isPassword ? "PASSWORD" : "CODE";
String authValue = et.getText().toString().trim();
if (authValue.isEmpty()) {
Toast.makeText(MainActivity.this, "请输入验证码或密码", Toast.LENGTH_SHORT).show();
return;
}
pendingAuthType = authType;
pendingAuthValue = authValue;
dialog.dismiss();
connectToControlled();
}));
dialog.show();
}
private void connectToControlled() {
String serverUrl = etServerUrl.getText().toString().trim();
targetDeviceId = etTargetDeviceId.getText().toString().trim();
@@ -283,7 +337,7 @@ public class MainActivity extends AppCompatActivity {
});
}
});
webRtcClient.createOffer(targetDeviceId, remoteVideoView);
webRtcClient.createOffer(targetDeviceId, remoteVideoView, pendingAuthType, pendingAuthValue);
}
private void handleSignalMessage(SignalMessage message) {
@@ -330,17 +384,29 @@ public class MainActivity extends AppCompatActivity {
*/
private void handleConnectionRejected(SignalMessage message) {
String targetId = message.getToDeviceId();
String payload = message.getPayload();
String text = (payload != null && !payload.isEmpty())
? payload
: "目标被控端拒绝了连接请求";
String finalText = text;
String reason = parseReason(message.getPayload());
String finalText = reason;
runOnUiThread(() -> {
tvStatus.setText("状态: " + finalText);
Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show();
updateUI(false);
});
Log.w(TAG, "Connection request to " + targetId + " was rejected by controlled device");
Log.w(TAG, "Connection request to " + targetId + " was rejected: " + reason);
}
/** 解析拒绝原因:优先读取 JSON 中的 reason 字段,否则直接返回原文。 */
private String parseReason(String payload) {
if (payload == null || payload.isEmpty()) {
return "目标被控端拒绝了连接请求";
}
try {
JsonObject obj = gson.fromJson(payload, JsonObject.class);
if (obj != null && obj.has("reason") && !obj.get("reason").isJsonNull()) {
return obj.get("reason").getAsString();
}
} catch (Exception ignored) {
}
return payload;
}
/**

View File

@@ -7,6 +7,8 @@ public class SignalMessage {
private String toDeviceId;
private String deviceType;
private String payload;
private String authType;
private String authValue;
public SignalMessage() {}
@@ -31,4 +33,10 @@ public class SignalMessage {
public String getPayload() { return payload; }
public void setPayload(String payload) { this.payload = payload; }
public String getAuthType() { return authType; }
public void setAuthType(String authType) { this.authType = authType; }
public String getAuthValue() { return authValue; }
public void setAuthValue(String authValue) { this.authValue = authValue; }
}

View File

@@ -41,6 +41,8 @@ public class WebRtcClient {
private final WebSocketClient wsClient;
private final String deviceId;
private final Gson gson = new Gson();
private String pendingAuthType;
private String pendingAuthValue;
private PeerConnectionFactory peerConnectionFactory;
private PeerConnection peerConnection;
@@ -89,8 +91,11 @@ public class WebRtcClient {
.createPeerConnectionFactory();
}
public void createOffer(String controlledId, SurfaceViewRenderer remoteRenderer) {
public void createOffer(String controlledId, SurfaceViewRenderer remoteRenderer,
String authType, String authValue) {
this.currentControlledId = controlledId;
this.pendingAuthType = authType;
this.pendingAuthValue = authValue;
List<PeerConnection.IceServer> iceServers = new ArrayList<>();
// 添加 STUN 和 TURN 服务器
@@ -309,6 +314,8 @@ public class WebRtcClient {
msg.setFromDeviceId(deviceId);
msg.setToDeviceId(controlledId);
msg.setDeviceType("CONTROLLER");
if (pendingAuthType != null) msg.setAuthType(pendingAuthType);
if (pendingAuthValue != null) msg.setAuthValue(pendingAuthValue);
JsonObject payload = new JsonObject();
payload.addProperty("sdp", sdp);

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:orientation="vertical"
android:padding="24dp">
<RadioGroup
android:id="@+id/rg_auth_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<RadioButton
android:id="@+id/rb_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="动态验证码" />
<RadioButton
android:id="@+id/rb_password"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="固定密码" />
</RadioGroup>
<EditText
android:id="@+id/et_auth_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="请输入动态验证码"
android:inputType="textVisiblePassword" />
<TextView
android:id="@+id/tv_auth_tip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="动态验证码为被控端界面显示的 6 位大小写字母与数字组合"
android:textSize="12sp"
android:textColor="#888888" />
</LinearLayout>

View File

@@ -3,9 +3,19 @@ import { ref } from 'vue';
import { store, connectSignaling, disconnectSignaling, refreshDevices, connectToDevice, disconnectDevice } from '../store/controllerStore';
const selected = ref('');
const authType = ref('CODE');
const authValue = ref('');
function onConnectDevice() {
connectToDevice(selected.value);
if (!selected.value) {
store.error = '请选择要连接的设备';
return;
}
if (!authValue.value.trim()) {
store.error = '请输入验证码或密码';
return;
}
connectToDevice(selected.value, authType.value, authValue.value.trim());
}
</script>
@@ -51,6 +61,15 @@ function onConnectDevice() {
</select>
</div>
<div class="section-title" style="margin-top:14px">连接鉴权</div>
<div class="auth-row">
<label class="auth-radio"><input type="radio" value="CODE" v-model="authType" /> 动态验证码</label>
<label class="auth-radio"><input type="radio" value="PASSWORD" v-model="authType" /> 固定密码</label>
</div>
<div class="field">
<input class="input" v-model="authValue" :placeholder="authType === 'PASSWORD' ? '请输入固定密码' : '请输入动态验证码'" />
</div>
<button class="btn" @click="onConnectDevice" :disabled="!selected || store.rtcConnected">发起远程控制</button>
<button class="btn danger" style="margin-top:12px" v-if="store.rtcConnected" @click="disconnectDevice">结束控制</button>
@@ -64,3 +83,8 @@ function onConnectDevice() {
</div>
</div>
</template>
<style scoped>
.auth-row { display: flex; gap: 16px; margin-bottom: 8px; align-items: center; }
.auth-radio { display: inline-flex; align-items: center; gap: 4px; }
</style>

View File

@@ -81,14 +81,17 @@ export class SignalingClient {
this.send({ type: 'DEVICE_LIST', fromDeviceId: this.deviceId });
}
sendOffer(sdp, toDeviceId) {
this.send({
sendOffer(sdp, toDeviceId, authType = null, authValue = null) {
const msg = {
type: 'OFFER',
fromDeviceId: this.deviceId,
toDeviceId,
deviceType: 'CONTROLLER',
payload: JSON.stringify({ sdp }),
});
};
if (authType) msg.authType = authType;
if (authValue != null) msg.authValue = authValue;
this.send(msg);
}
sendIceCandidate(candidate, toDeviceId) {

View File

@@ -35,7 +35,7 @@ export class WebRtcController {
this._pendingCandidates = [];
}
async createOffer() {
async createOffer(authType = null, authValue = null) {
this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
this.pc.onicecandidate = (e) => {
@@ -88,7 +88,7 @@ export class WebRtcController {
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.signaling.sendOffer(this.pc.localDescription.sdp, this.targetDeviceId);
this.signaling.sendOffer(this.pc.localDescription.sdp, this.targetDeviceId, authType, authValue);
this._startStats();
}

View File

@@ -120,7 +120,7 @@ export function refreshDevices() {
signaling && signaling.requestDeviceList();
}
export async function connectToDevice(targetId) {
export async function connectToDevice(targetId, authType = null, authValue = null) {
if (!signaling || !store.signalingConnected) { store.error = '请先连接信令服务器'; return; }
if (!targetId) { store.error = '请选择要控制的被控端设备'; return; }
@@ -147,7 +147,7 @@ export async function connectToDevice(targetId) {
// 被控端上报当前实际采集分辨率,同步到 store 供 UI 展示/匹配预设。
onResolutionReport: (res) => { store.currentResolution = res; },
});
await webrtc.createOffer();
await webrtc.createOffer(authType, authValue);
} catch (e) {
// 捕获 createOffer 阶段的所有异常,避免未处理的 Promise 拒绝导致浏览器中断脚本执行、
// 间接影响 WebSocket 信令连接的存活。

View File

@@ -189,6 +189,21 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
return;
}
// 鉴权类型分类:动态验证码(CODE) / 固定密码(PASSWORD)。
// 服务器只做分类与转发,真实校验在被控端完成(服务器不保存任何密钥)。
String authType = message.getAuthType();
if (authType != null && !authType.trim().isEmpty()) {
if ("CODE".equalsIgnoreCase(authType)) {
logger.info("连接请求 {} -> {} 使用【动态验证码】鉴权", fromDeviceId, toDeviceId);
} else if ("PASSWORD".equalsIgnoreCase(authType)) {
logger.info("连接请求 {} -> {} 使用【固定密码】鉴权", fromDeviceId, toDeviceId);
} else {
logger.warn("连接请求 {} -> {} 携带未知鉴权类型 authType={}", fromDeviceId, toDeviceId, authType);
}
} else {
logger.info("连接请求 {} -> {} 未携带鉴权(走被控端手动确认)", fromDeviceId, toDeviceId);
}
// 2. 去重:短时间内重复 OFFER 直接忽略,避免被控端反复弹窗
if (connectionRequestManager.isDuplicateOffer(fromDeviceId, toDeviceId)) {
logger.info("Duplicate connection request {} -> {} ignored", fromDeviceId, toDeviceId);

View File

@@ -10,6 +10,8 @@ public class SignalMessage {
private String toDeviceId; // 目标设备ID
private String deviceType; // CONTROLLER 或 CONTROLLED
private String payload; // JSON格式的信令数据SDP/ICE/控制指令)
private String authType; // 鉴权类型CODE动态验证码/ PASSWORD固定密码
private String authValue; // 鉴权值:动态验证码或固定密码
public SignalMessage() {}
@@ -27,4 +29,10 @@ public class SignalMessage {
public String getPayload() { return payload; }
public void setPayload(String payload) { this.payload = payload; }
public String getAuthType() { return authType; }
public void setAuthType(String authType) { this.authType = authType; }
public String getAuthValue() { return authValue; }
public void setAuthValue(String authValue) { this.authValue = authValue; }
}

View File

@@ -14,6 +14,8 @@ class RemoteController {
final String serverUrl;
final String deviceId;
final String targetDeviceId;
final String? authType;
final String? authValue;
late final SignalingClient _signaling;
WebRtcController? _webRtc;
@@ -47,10 +49,14 @@ class RemoteController {
required this.serverUrl,
required this.deviceId,
required this.targetDeviceId,
this.authType,
this.authValue,
});
/// 发起连接:先连接信令服务器,成功后建立 WebRTC 并创建 Offer。
void connect() {
void connect({String? authType, String? authValue}) {
this.authType = authType;
this.authValue = authValue;
onStatusChanged?.call('状态: 正在连接信令服务器...');
_signaling = SignalingClient(serverUrl: serverUrl, deviceId: deviceId);
@@ -74,6 +80,8 @@ class RemoteController {
signaling: _signaling,
deviceId: deviceId,
targetDeviceId: targetDeviceId,
authType: authType,
authValue: authValue,
);
_webRtc!.onConnectionEstablished = () {
onConnectionEstablished?.call();
@@ -109,13 +117,24 @@ class RemoteController {
case 'CONNECTION_REJECTED':
case 'REQUEST_ERROR':
case 'REQUEST_TIMEOUT':
final text = message.payload ?? '连接请求失败';
final text = _parseRejectReason(message.payload);
onStatusChanged?.call('状态: $text');
onConnectionRejected?.call(text);
break;
}
}
/// 解析拒绝原因:优先读取 JSON 中的 reason 字段,否则直接返回原文。
String _parseRejectReason(String? payload) {
if (payload == null || payload.isEmpty) return '连接请求失败';
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final r = map['reason'];
if (r is String && r.isNotEmpty) return r;
} catch (_) {}
return payload;
}
void _startStats() {
_statsTimer?.cancel();
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) async {

View File

@@ -99,7 +99,73 @@ class _ControllerHomeState extends State<ControllerHome> {
);
}
Future<void> _connect() async {
Future<void> _showAuthDialog() async {
final serverUrl = _serverUrlController.text.trim();
final deviceId = _deviceIdController.text.trim();
final target = _targetController.text.trim();
if (serverUrl.isEmpty || deviceId.isEmpty || target.isEmpty) {
_setStatus('请填写所有字段');
return;
}
String selectedType = 'CODE';
final valueController = TextEditingController();
await showCupertinoDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => CupertinoAlertDialog(
title: const Text('连接鉴权'),
content: Column(
children: [
const SizedBox(height: 12),
CupertinoSegmentedControl<String>(
children: const {
'CODE': Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('动态验证码'),
),
'PASSWORD': Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('固定密码'),
),
},
groupValue: selectedType,
onValueChanged: (v) => setDialogState(() => selectedType = v),
),
const SizedBox(height: 12),
CupertinoTextField(
controller: valueController,
placeholder: '请输入验证码或密码',
obscureText: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
],
),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.of(ctx).pop(),
),
CupertinoDialogAction(
child: const Text('连接'),
onPressed: () {
final val = valueController.text.trim();
if (val.isEmpty) {
_showAlert('请输入验证码或密码');
return;
}
Navigator.of(ctx).pop();
_connect(authType: selectedType, authValue: val);
},
),
],
),
),
);
}
Future<void> _connect({required String authType, required String authValue}) async {
final serverUrl = _serverUrlController.text.trim();
final deviceId = _deviceIdController.text.trim();
final target = _targetController.text.trim();
@@ -115,6 +181,8 @@ class _ControllerHomeState extends State<ControllerHome> {
serverUrl: serverUrl,
deviceId: deviceId,
targetDeviceId: target,
authType: authType,
authValue: authValue,
);
_controller!.onStatusChanged = _setStatus;
_controller!.onConnectionEstablished = () {
@@ -142,7 +210,7 @@ class _ControllerHomeState extends State<ControllerHome> {
};
_controller!.onStats = (stats) => setState(() => _stats = stats);
_controller!.connect();
_controller!.connect(authType: authType, authValue: authValue);
}
void _onRendererUpdate() {
@@ -241,7 +309,7 @@ class _ControllerHomeState extends State<ControllerHome> {
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: _connect,
onPressed: _showAuthDialog,
child: const Text('连接被控设备'),
),
),

View File

@@ -8,12 +8,16 @@ import 'dart:convert';
/// - [toDeviceId] 接收方设备 ID
/// - [deviceType] 设备类型CONTROLLER / CONTROLLED
/// - [payload] JSON 字符串形式的负载SDP / ICE 候选等)
/// - [authType] 鉴权类型CODE动态验证码/ PASSWORD固定密码
/// - [authValue] 鉴权值:动态验证码或固定密码
class SignalMessage {
final String? type;
final String? fromDeviceId;
final String? toDeviceId;
final String? deviceType;
final String? payload;
final String? authType;
final String? authValue;
const SignalMessage({
this.type,
@@ -21,6 +25,8 @@ class SignalMessage {
this.toDeviceId,
this.deviceType,
this.payload,
this.authType,
this.authValue,
});
factory SignalMessage.fromJson(Map<String, dynamic> json) => SignalMessage(
@@ -29,6 +35,8 @@ class SignalMessage {
toDeviceId: json['toDeviceId'] as String?,
deviceType: json['deviceType'] as String?,
payload: json['payload'] as String?,
authType: json['authType'] as String?,
authValue: json['authValue'] as String?,
);
Map<String, dynamic> toJson() => {
@@ -37,6 +45,8 @@ class SignalMessage {
if (toDeviceId != null) 'toDeviceId': toDeviceId,
if (deviceType != null) 'deviceType': deviceType,
if (payload != null) 'payload': payload,
if (authType != null) 'authType': authType,
if (authValue != null) 'authValue': authValue,
};
/// 便捷构造方法payload 为任意 Map会自动序列化为 JSON 字符串。
@@ -46,6 +56,8 @@ class SignalMessage {
required String toDeviceId,
required String deviceType,
required Map<String, dynamic> payload,
String? authType,
String? authValue,
}) {
return SignalMessage(
type: type,
@@ -53,6 +65,8 @@ class SignalMessage {
toDeviceId: toDeviceId,
deviceType: deviceType,
payload: jsonEncode(payload),
authType: authType,
authValue: authValue,
);
}

View File

@@ -21,6 +21,8 @@ class WebRtcController {
final SignalingClient signaling;
final String deviceId;
final String targetDeviceId;
final String? authType;
final String? authValue;
RTCPeerConnection? _pc;
RTCDataChannel? _dataChannel;
@@ -42,6 +44,8 @@ class WebRtcController {
required this.signaling,
required this.deviceId,
required this.targetDeviceId,
this.authType,
this.authValue,
});
/// 初始化渲染器、PeerConnection并创建 Offer。
@@ -89,7 +93,7 @@ class WebRtcController {
};
final offer = await _pc!.createOffer(constraints);
await _pc!.setLocalDescription(offer);
_sendOffer(offer.sdp!);
_sendOffer(offer.sdp!, authType: authType, authValue: authValue);
}
void _onTrack(RTCTrackEvent event) {
@@ -157,7 +161,7 @@ class WebRtcController {
}
}
void _sendOffer(String sdp) {
void _sendOffer(String sdp, {String? authType, String? authValue}) {
final payload = {'sdp': sdp};
final msg = SignalMessage.withPayload(
type: 'OFFER',
@@ -165,6 +169,8 @@ class WebRtcController {
toDeviceId: targetDeviceId,
deviceType: 'CONTROLLER',
payload: payload,
authType: authType,
authValue: authValue,
);
signaling.send(msg);
}