diff --git a/WebRTCControlled/app/src/main/AndroidManifest.xml b/WebRTCControlled/app/src/main/AndroidManifest.xml
index bc41e29..4dafa1a 100644
--- a/WebRTCControlled/app/src/main/AndroidManifest.xml
+++ b/WebRTCControlled/app/src/main/AndroidManifest.xml
@@ -25,6 +25,11 @@
+
+
onAccept());
+ builder.setNegativeButton(R.string.connection_reject, (d, which) -> onReject());
+
+ dialog = builder.create();
+ dialog.setOnDismissListener(d -> {
+ // 对话框因任何原因关闭(按钮点击或超时触发)时,确保移除定时任务。
+ timeoutHandler.removeCallbacksAndMessages(null);
+ finishIfNeeded();
+ });
+ dialog.show();
+
+ // 启动自动拒绝倒计时。
+ timeoutHandler.postDelayed(this::onTimeout, AUTO_REJECT_TIMEOUT);
+ }
+
+ private void onAccept() {
+ if (finished) return;
+ Log.i(TAG, "User accepted connection request");
+ ScreenCaptureService service = ScreenCaptureService.getInstance();
+ if (service != null) {
+ service.acceptConnection();
+ }
+ finished = true;
+ }
+
+ private void onReject() {
+ if (finished) return;
+ Log.i(TAG, "User rejected connection request");
+ ScreenCaptureService service = ScreenCaptureService.getInstance();
+ if (service != null) {
+ service.rejectConnection();
+ }
+ finished = true;
+ }
+
+ private void onTimeout() {
+ if (finished) return;
+ Log.i(TAG, "Connection request timed out, auto rejecting");
+ ScreenCaptureService service = ScreenCaptureService.getInstance();
+ if (service != null) {
+ service.rejectConnection();
+ }
+ finished = true;
+ if (dialog != null && dialog.isShowing()) {
+ dialog.dismiss();
+ }
+ }
+
+ private void finishIfNeeded() {
+ if (!isFinishing() && !isDestroyed()) {
+ finish();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ timeoutHandler.removeCallbacksAndMessages(null);
+ if (dialog != null && dialog.isShowing()) {
+ dialog.dismiss();
+ }
+ }
+}
diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java
index a2ca738..3d78310 100644
--- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java
+++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java
@@ -25,7 +25,8 @@ import com.ttstd.controlled.webrtc.WebRtcClient;
import org.webrtc.RendererCommon;
-public class MainActivity extends BaseMvvmActivity {
+public class MainActivity extends BaseMvvmActivity
+ implements ScreenCaptureService.ServiceStateListener {
private static final String TAG = "ControlledMain";
private static final int REQUEST_MEDIA_PROJECTION = 1001;
@@ -42,6 +43,8 @@ public class MainActivity extends BaseMvvmActivity {
+ // 信令服务器断开:刷新为已停止状态,并解绑/停止服务释放资源。
+ updateUI(false);
+ stopScreenSharing();
+ });
+ }
+
+ @Override
+ public void onControllerDisconnected(String name) {
+ runOnUiThread(() -> {
+ // 控制端断开但信令仍在线:更新状态文本提示,保持可重新连接。
+ binding.tvStatus.setText("状态: 控制端(" + name + ")已断开");
+ });
+ }
+
private void updateUI(boolean running) {
isServiceRunning = running;
binding.btnStart.setEnabled(!running);
diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java
index 13ac648..5e51dce 100644
--- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java
+++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java
@@ -13,7 +13,11 @@ import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
import android.os.Binder;
import android.os.Build;
+import android.os.Handler;
import android.os.IBinder;
+import android.os.Looper;
+import android.text.TextUtils;
+import android.widget.Toast;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.WindowManager;
@@ -21,6 +25,8 @@ import android.view.WindowManager;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
+import com.ttstd.controlled.R;
+import com.ttstd.controlled.activity.connection.ConnectionRequestActivity;
import com.ttstd.controlled.activity.main.MainActivity;
import com.ttstd.controlled.input.InputCommandHandler;
import com.ttstd.controlled.signaling.SignalMessage;
@@ -53,9 +59,30 @@ public class ScreenCaptureService extends Service {
private EglBase eglBase;
private final Gson gson = new Gson();
+ /** 静态实例引用,供弹窗 Activity 在无需绑定的情况下回调本服务。 */
+ private static ScreenCaptureService instance;
+
+ public static ScreenCaptureService getInstance() {
+ return instance;
+ }
+
+ /** 本设备 ID(注册到信令服务器用),保存为字段以便弹窗回调使用。 */
+ private String deviceId;
+ /** 当前待处理的连接请求(OFFER)信息,供用户在弹窗中确认或拒绝。 */
+ private String pendingControllerId;
+ private String pendingControllerName;
+ private String pendingOfferSdp;
+ /** 当前正在控制本设备的控制端名称(用户名或设备 ID),用于断开提示。 */
+ private String controllingName;
+ /** 主线程 Handler,用于从 WebRTC 回调线程切回主线程执行 UI 提示。 */
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
+ /** 标记服务是否正在关闭,避免重复停止/重复弹窗。 */
+ private boolean isShuttingDown = false;
+
@Override
public void onCreate() {
super.onCreate();
+ instance = this;
createNotificationChannel();
}
@@ -77,7 +104,7 @@ public class ScreenCaptureService extends Service {
int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED);
Intent resultData = intent.getParcelableExtra(EXTRA_RESULT_DATA);
String serverUrl = intent.getStringExtra(EXTRA_SERVER_URL);
- String deviceId = intent.getStringExtra(EXTRA_DEVICE_ID);
+ deviceId = intent.getStringExtra(EXTRA_DEVICE_ID);
if (resultCode != Activity.RESULT_OK || resultData == null || serverUrl == null || deviceId == null) {
Log.e(TAG, "Invalid service parameters");
@@ -151,9 +178,29 @@ public class ScreenCaptureService extends Service {
return webRtcClient;
}
+ /**
+ * 状态回调。由已 bind 的 MainActivity 注册,用于把连接状态同步到界面。
+ */
+ public interface ServiceStateListener {
+ /** 信令服务器断开(WebSocket 断开)。 */
+ void onSignalDisconnected();
+ /** 远程控制端断开,参数为控制端名称(用户名或设备 ID)。 */
+ void onControllerDisconnected(String name);
+ }
+
+ private ServiceStateListener stateListener;
+
+ public void setStateListener(ServiceStateListener listener) {
+ this.stateListener = listener;
+ }
+
@Override
public void onDestroy() {
super.onDestroy();
+ isShuttingDown = true;
+ if (instance == this) {
+ instance = null;
+ }
if (screenCapturer != null) {
screenCapturer.stopCapture();
screenCapturer.dispose();
@@ -192,11 +239,15 @@ public class ScreenCaptureService extends Service {
@Override
public void onDisconnected() {
Log.i(TAG, "Disconnected from signal server");
+ mainHandler.post(() -> onSignalServerDisconnected());
}
@Override
public void onError(String error) {
Log.e(TAG, "WebSocket error: " + error);
+ if(TextUtils.isEmpty(error)){
+ mainHandler.post(() -> onSignalServerDisconnected());
+ }
}
@Override
@@ -210,6 +261,9 @@ public class ScreenCaptureService extends Service {
webRtcClient = new WebRtcClient(this, wsClient, deviceId);
webRtcClient.initialize(eglBase);
webRtcClient.setInputCallback(commandBytes -> inputHandler.handleCommand(commandBytes));
+ // 注册远程控制断开回调:控制端断线时提示其用户名。
+ webRtcClient.setRemoteDisconnectCallback(controllerId ->
+ mainHandler.post(() -> onControllerDisconnected(controllerId)));
// 初始化屏幕捕获
screenCapturer = new ScreenCapturerAndroid(resultData, new MediaProjection.Callback() {
@@ -234,17 +288,122 @@ public class ScreenCaptureService extends Service {
}
private void handleOffer(SignalMessage message) {
+ // 已有待确认请求时不重复弹窗,避免覆盖当前对话框的状态。
+ if (pendingControllerId != null) {
+ Log.i(TAG, "Ignore OFFER from " + message.getFromDeviceId() + ": a request is already pending");
+ return;
+ }
+
String offerSdp = null;
+ String controllerName = null;
try {
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
offerSdp = payload.get("sdp").getAsString();
+ // 控制端若在 OFFER 中携带用户名则使用,否则回退到设备 ID。
+ if (payload.has("username") && !payload.get("username").isJsonNull()) {
+ controllerName = payload.get("username").getAsString();
+ }
} catch (Exception e) {
Log.e(TAG, "Error parsing offer SDP", e);
return;
}
String controllerId = message.getFromDeviceId();
- webRtcClient.createPeerConnectionAndAnswer(offerSdp, controllerId);
+ // 暂存请求,弹出确认对话框,交由用户决定是否接受。
+ pendingControllerId = controllerId;
+ pendingControllerName = (controllerName != null && !controllerName.isEmpty())
+ ? controllerName : controllerId;
+ pendingOfferSdp = offerSdp;
+
+ Intent dialogIntent = new Intent(this, ConnectionRequestActivity.class);
+ dialogIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ dialogIntent.putExtra(ConnectionRequestActivity.EXTRA_CONTROLLER_ID, controllerId);
+ startActivity(dialogIntent);
+ }
+
+ /**
+ * 用户在弹窗中点击“接受”:创建 Answer 完成连接。
+ */
+ public void acceptConnection() {
+ if (pendingOfferSdp != null && pendingControllerId != null && webRtcClient != null) {
+ String offerSdp = pendingOfferSdp;
+ String controllerId = pendingControllerId;
+ controllingName = pendingControllerName;
+ clearPendingRequest();
+ webRtcClient.createPeerConnectionAndAnswer(offerSdp, controllerId);
+ }
+ }
+
+ /**
+ * 用户在弹窗中点击“拒绝”:向主控端回送拒绝通知,不建立连接。
+ */
+ public void rejectConnection() {
+ if (pendingControllerId != null && wsClient != null) {
+ String controllerId = pendingControllerId;
+ clearPendingRequest();
+ sendConnectionRejected(controllerId);
+ }
+ }
+
+ private void clearPendingRequest() {
+ pendingControllerId = null;
+ pendingControllerName = null;
+ pendingOfferSdp = null;
+ }
+
+ /**
+ * 信令服务器断开:通过已绑定的 MainActivity 同步状态并停止屏幕共享,
+ * 由 MainActivity 负责解绑/停止服务并刷新界面,避免直接在服务内
+ * stopSelf 而在 bound 状态下无法真正销毁的问题。
+ */
+ private void onSignalServerDisconnected() {
+ if (isShuttingDown) return;
+ isShuttingDown = true;
+ Log.i(TAG, "Signal server disconnected, stopping screen sharing");
+ showToast(getString(R.string.signal_disconnected_message));
+ if (stateListener != null) {
+ stateListener.onSignalDisconnected();
+ }
+ }
+
+ /**
+ * 远程控制端断开:提示当前控制端(用户名/设备 ID)已断开连接。
+ * 仅提示并同步状态,不停止服务,以便被控端可继续接收新的连接请求。
+ */
+ private void onControllerDisconnected(String controllerId) {
+ if (isShuttingDown) return;
+ String name = (controllingName != null && !controllingName.isEmpty())
+ ? controllingName : controllerId;
+ Log.i(TAG, "Remote controller disconnected: " + name);
+ showToast(getString(R.string.controller_disconnected_message, name));
+ if (stateListener != null) {
+ stateListener.onControllerDisconnected(name);
+ }
+ // 断开后清空控制端身份,允许后续重新接受新的连接。
+ controllingName = null;
+ }
+
+ private void showToast(String text) {
+ try {
+ Toast.makeText(this, text, Toast.LENGTH_LONG).show();
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to show toast: " + e.getMessage());
+ }
+ }
+
+ private void sendConnectionRejected(String controllerId) {
+ SignalMessage msg = new SignalMessage();
+ msg.setType("CONNECTION_REJECTED");
+ msg.setFromDeviceId(deviceId);
+ msg.setToDeviceId(controllerId);
+ msg.setDeviceType("CONTROLLED");
+
+ JsonObject payload = new JsonObject();
+ payload.addProperty("reason", "被控端拒绝了远程连接请求");
+ msg.setPayload(payload.toString());
+
+ wsClient.sendMessage(msg);
+ Log.i(TAG, "Sent CONNECTION_REJECTED to " + controllerId);
}
private void handleIceCandidate(SignalMessage message) {
diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java
index 0e94de5..c21b9e9 100644
--- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java
+++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java
@@ -65,10 +65,18 @@ public class WebRtcClient {
private InputCommandCallback inputCallback;
+ private RemoteDisconnectCallback remoteDisconnectCallback;
+ private boolean remoteDisconnectNotified = false;
+
public interface InputCommandCallback {
void onCommandReceived(byte[] data);
}
+ /** 远程控制端(PeerConnection / DataChannel)断开时的回调。 */
+ public interface RemoteDisconnectCallback {
+ void onRemoteDisconnected(String controllerId);
+ }
+
public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) {
this.context = context;
this.wsClient = wsClient;
@@ -79,6 +87,18 @@ public class WebRtcClient {
this.inputCallback = callback;
}
+ public void setRemoteDisconnectCallback(RemoteDisconnectCallback callback) {
+ this.remoteDisconnectCallback = callback;
+ }
+
+ private void notifyRemoteDisconnected() {
+ if (remoteDisconnectNotified) return;
+ remoteDisconnectNotified = true;
+ if (remoteDisconnectCallback != null) {
+ remoteDisconnectCallback.onRemoteDisconnected(currentControllerId);
+ }
+ }
+
public void initialize(EglBase eglBase) {
this.eglBase = eglBase;
PeerConnectionFactory.InitializationOptions initOptions =
@@ -141,6 +161,7 @@ public class WebRtcClient {
closeCurrentConnection();
}
this.currentControllerId = controllerId;
+ this.remoteDisconnectNotified = false;
List iceServers = new ArrayList<>();
// 建议:如果你有 TURN 服务器,请务必在此处添加,例如:
@@ -171,6 +192,11 @@ public class WebRtcClient {
Log.d(TAG, "ICE connection state: " + newState);
if (newState == PeerConnection.IceConnectionState.CONNECTED) {
refreshVideoSource();
+ } else if (newState == PeerConnection.IceConnectionState.DISCONNECTED
+ || newState == PeerConnection.IceConnectionState.FAILED
+ || newState == PeerConnection.IceConnectionState.CLOSED) {
+ // 远程控制端断开(网络中断 / 主动退出),通知上层提示用户。
+ notifyRemoteDisconnected();
}
}
@@ -319,6 +345,9 @@ public class WebRtcClient {
@Override
public void onStateChange() {
Log.d(TAG, "DataChannel state: " + dc.state());
+ if (dc.state() == DataChannel.State.CLOSED) {
+ notifyRemoteDisconnected();
+ }
}
@Override
diff --git a/WebRTCControlled/app/src/main/res/values/strings.xml b/WebRTCControlled/app/src/main/res/values/strings.xml
index 9d90649..dc49b8b 100644
--- a/WebRTCControlled/app/src/main/res/values/strings.xml
+++ b/WebRTCControlled/app/src/main/res/values/strings.xml
@@ -1,4 +1,10 @@
WebRTC被控端
+ 远程连接请求
+ 设备 %s 请求远程控制您的屏幕,是否允许?
+ 接受
+ 拒绝
+ 与信令服务器断开,屏幕共享已停止
+ 控制端(%s)已断开远程控制
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java b/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java
index 312d8d3..3a16b07 100644
--- a/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java
+++ b/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java
@@ -289,6 +289,13 @@ public class MainActivity extends AppCompatActivity {
case "TARGET_OFFLINE":
handleTargetOffline(message);
break;
+ case "CONNECTION_REJECTED":
+ handleConnectionRejected(message);
+ break;
+ case "REQUEST_ERROR":
+ case "REQUEST_TIMEOUT":
+ handleRequestAborted(message);
+ break;
}
}
@@ -307,11 +314,48 @@ public class MainActivity extends AppCompatActivity {
Log.w(TAG, "Target device " + offlineId + " is offline");
}
+ /**
+ * 被控端拒绝了连接请求:提示用户并复位到未连接状态。
+ */
+ private void handleConnectionRejected(SignalMessage message) {
+ String targetId = message.getToDeviceId();
+ String payload = message.getPayload();
+ String text = (payload != null && !payload.isEmpty())
+ ? payload
+ : "目标被控端拒绝了连接请求";
+ String finalText = text;
+ 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");
+ }
+
+ /**
+ * 连接请求被服务器拒绝(REQUEST_ERROR)或应答超时(REQUEST_TIMEOUT):提示并复位。
+ */
+ private void handleRequestAborted(SignalMessage message) {
+ String payload = message.getPayload();
+ String text = (payload != null && !payload.isEmpty())
+ ? payload
+ : "连接请求失败";
+ String finalText = text;
+ runOnUiThread(() -> {
+ tvStatus.setText("状态: " + finalText);
+ Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show();
+ updateUI(false);
+ });
+ Log.w(TAG, "Connection request aborted: " + finalText);
+ }
+
private void handleAnswer(SignalMessage message) {
try {
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
String sdp = payload.get("sdp").getAsString();
if (webRtcClient != null) {
+ // 被控端已接受连接请求,进入 WebRTC 协商阶段。
+ runOnUiThread(() -> tvStatus.setText("状态: 被控端已接受连接,正在建立连接..."));
webRtcClient.handleAnswer(sdp);
}
} catch (Exception e) {
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java
index 3e6a361..da6c387 100644
--- a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java
+++ b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java
@@ -285,6 +285,7 @@ public class WebRtcClient {
}
private void sendOffer(String sdp, String controlledId) {
+ Log.e(TAG, "sendOffer: " );
SignalMessage msg = new SignalMessage();
msg.setType("OFFER");
msg.setFromDeviceId(deviceId);
@@ -299,6 +300,7 @@ public class WebRtcClient {
}
private void sendIceCandidate(IceCandidate candidate, String controlledId) {
+ Log.e(TAG, "sendIceCandidate: " );
SignalMessage msg = new SignalMessage();
msg.setType("ICE_CANDIDATE");
msg.setFromDeviceId(deviceId);
diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java
index 8c42a64..a8a63b6 100644
--- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java
+++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java
@@ -1,6 +1,7 @@
package com.ttstd.signaling.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ttstd.signaling.manager.ConnectionRequestManager;
import com.ttstd.signaling.manager.SessionManager;
import com.ttstd.signaling.model.DeviceType;
import com.ttstd.signaling.model.SignalMessage;
@@ -24,9 +25,24 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
private static final ObjectMapper objectMapper = new ObjectMapper();
private final SessionManager sessionManager;
+ private final ConnectionRequestManager connectionRequestManager;
- public SignalWebSocketHandler(SessionManager sessionManager) {
+ public SignalWebSocketHandler(SessionManager sessionManager, ConnectionRequestManager connectionRequestManager) {
this.sessionManager = sessionManager;
+ this.connectionRequestManager = connectionRequestManager;
+ this.connectionRequestManager.setSender(this::sendToDevice);
+ }
+
+ /**
+ * 向指定设备回送服务端事件(错误/超时通知等)。
+ */
+ private void sendToDevice(String deviceId, Object message) {
+ WebSocketSession session = sessionManager.getSession(deviceId);
+ if (session == null || !session.isOpen()) {
+ logger.warn("Cannot send event to {}: device offline", deviceId);
+ return;
+ }
+ sendToSession(session, message);
}
@Override
@@ -56,10 +72,21 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
handleDeviceList(session, signalMessage);
break;
case "OFFER":
+ // 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发
+ handleConnectionRequest(signalMessage);
+ break;
case "ANSWER":
- // 关键信令:目标不在线时回送 TARGET_OFFLINE 提醒发送方
+ // 被控端已接受:清理待确认状态并转发 Answer 给主控端
+ connectionRequestManager.completePendingRequest(
+ signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
forwardMessage(signalMessage, true);
break;
+ case "CONNECTION_REJECTED":
+ // 被控端已拒绝:清理待确认状态并转发拒绝通知给主控端
+ connectionRequestManager.completePendingRequest(
+ signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
+ forwardMessage(signalMessage, false);
+ break;
case "ICE_CANDIDATE":
case "CONTROL_COMMAND":
// 普通信令:目标不在线时静默丢弃,不返回提示
@@ -126,6 +153,44 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
sendToSession(session, response);
}
+ /**
+ * 处理主控端发来的连接请求(OFFER):先做设备类型校验与去重,
+ * 再登记“待被控端确认”状态,最后转发给被控端。
+ */
+ private void handleConnectionRequest(SignalMessage message) {
+ String fromDeviceId = message.getFromDeviceId();
+ String toDeviceId = message.getToDeviceId();
+
+ // 1. 校验:仅允许 CONTROLLER -> CONTROLLED
+ String error = connectionRequestManager.validateOffer(fromDeviceId, toDeviceId);
+ if (error != null) {
+ logger.warn("Invalid connection request {} -> {}: {}", fromDeviceId, toDeviceId, error);
+ Map response = new HashMap<>();
+ response.put("type", "REQUEST_ERROR");
+ response.put("toDeviceId", toDeviceId);
+ response.put("payload", error);
+ sendToDevice(fromDeviceId, response);
+ return;
+ }
+
+ // 2. 去重:短时间内重复 OFFER 直接忽略,避免被控端反复弹窗
+ if (connectionRequestManager.isDuplicateOffer(fromDeviceId, toDeviceId)) {
+ logger.info("Duplicate connection request {} -> {} ignored", fromDeviceId, toDeviceId);
+ return;
+ }
+
+ // 3. 目标不在线:立即回送 TARGET_OFFLINE,且不登记待确认(避免等待超时)
+ if (!sessionManager.isDeviceOnline(toDeviceId)) {
+ logger.warn("Target device {} is offline, cannot deliver connection request", toDeviceId);
+ notifySenderTargetOffline(message, toDeviceId);
+ return;
+ }
+
+ // 4. 登记待确认并转发 OFFER 给被控端
+ connectionRequestManager.registerPendingRequest(fromDeviceId, toDeviceId);
+ forwardMessage(message, false);
+ }
+
private void forwardMessage(SignalMessage message, boolean notifyOffline) {
String toDeviceId = message.getToDeviceId();
if (toDeviceId == null) {
diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/ConnectionRequestManager.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/ConnectionRequestManager.java
new file mode 100644
index 0000000..4f3adbc
--- /dev/null
+++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/ConnectionRequestManager.java
@@ -0,0 +1,153 @@
+package com.ttstd.signaling.manager;
+
+import com.ttstd.signaling.model.DeviceType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 连接请求管理器:对主控端发往被控端的 OFFER(连接请求)进行统一治理。
+ *
+ * 职责:
+ *
+ * - 校验:OFFER 仅允许由 CONTROLLER 发往 CONTROLLED,否则拒绝并向发送方报错。
+ * - 去重:同一主控端对同一被控端的重复 OFFER(如网络重传/重复点击)在短时间内被忽略,
+ * 避免被控端反复弹窗。
+ * - 待确认跟踪:记录处于“等待被控端确认”状态的请求,收到 ANSWER(接受)或
+ * CONNECTION_REJECTED(拒绝)时清理;超时未决则通知主控端请求超时。
+ *
+ */
+@Component
+public class ConnectionRequestManager {
+
+ private static final Logger logger = LoggerFactory.getLogger(ConnectionRequestManager.class);
+
+ /** 同一连接的去重窗口:窗口内重复的 OFFER 将被忽略。 */
+ private static final long DEDUP_WINDOW_MS = 5_000;
+ /** 被控端确认超时:超过该时间既无 ANSWER 也无拒绝,则通知主控端超时。 */
+ private static final long REQUEST_TIMEOUT_MS = 60_000;
+
+ private final SessionManager sessionManager;
+ private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "connection-request-timeout");
+ t.setDaemon(true);
+ return t;
+ });
+
+ /** 最近一次 OFFER 的时间戳,键为 "fromDeviceId->toDeviceId"。 */
+ private final Map lastOfferTime = new ConcurrentHashMap<>();
+ /** 处于“等待被控端确认”状态的请求,键同上。 */
+ private final Map pendingRequests = new ConcurrentHashMap<>();
+
+ public ConnectionRequestManager(SessionManager sessionManager) {
+ this.sessionManager = sessionManager;
+ }
+
+ /** 向指定设备发送消息的回调(由 Handler 注入,用于回送错误/超时通知)。 */
+ public interface SignalSender {
+ void send(String deviceId, Object message);
+ }
+
+ private SignalSender sender;
+
+ public void setSender(SignalSender sender) {
+ this.sender = sender;
+ }
+
+ private static String keyOf(String from, String to) {
+ return from + "->" + to;
+ }
+
+ /**
+ * 校验 OFFER 的合法性(发送方必须为 CONTROLLER,接收方必须为 CONTROLLED)。
+ *
+ * @return 校验失败时的错误信息(含发送目标),成功返回 null。
+ */
+ public String validateOffer(String fromDeviceId, String toDeviceId) {
+ if (fromDeviceId == null || toDeviceId == null) {
+ return "连接请求缺少发送方或接收方设备ID";
+ }
+ DeviceType fromType = sessionManager.getDeviceType(fromDeviceId);
+ DeviceType toType = sessionManager.getDeviceType(toDeviceId);
+ if (fromType != DeviceType.CONTROLLER) {
+ return "连接请求只能由主控端(CONTROLLER)发起";
+ }
+ if (toType != DeviceType.CONTROLLED) {
+ return "连接请求的目标必须是被控端(CONTROLLED)";
+ }
+ return null;
+ }
+
+ /**
+ * 去重判断:若同一连接的最近一次 OFFER 在去重窗口内,则视为重复。
+ */
+ public boolean isDuplicateOffer(String fromDeviceId, String toDeviceId) {
+ String key = keyOf(fromDeviceId, toDeviceId);
+ long now = System.currentTimeMillis();
+ Long last = lastOfferTime.get(key);
+ if (last != null && (now - last) < DEDUP_WINDOW_MS) {
+ return true;
+ }
+ lastOfferTime.put(key, now);
+ return false;
+ }
+
+ /**
+ * 登记一个待确认的连接请求,并安排超时任务。
+ */
+ public void registerPendingRequest(String fromDeviceId, String toDeviceId) {
+ String key = keyOf(fromDeviceId, toDeviceId);
+ PendingRequest pending = new PendingRequest(fromDeviceId, toDeviceId);
+ pending.timeoutTask = scheduler.schedule(() -> {
+ // 超时:仅当该请求仍未被接受/拒绝时通知主控端。
+ if (pendingRequests.remove(key, pending)) {
+ logger.warn("Connection request {} timed out after {}ms", key, REQUEST_TIMEOUT_MS);
+ if (sender != null) {
+ sender.send(fromDeviceId, buildEvent("REQUEST_TIMEOUT", toDeviceId,
+ "被控端(" + toDeviceId + ")未在限定时间内响应连接请求,请稍后重试"));
+ }
+ }
+ }, REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ pendingRequests.put(key, pending);
+ logger.info("Registered pending connection request {}", key);
+ }
+
+ /**
+ * 当被控端接受(收到 ANSWER)或拒绝(收到 CONNECTION_REJECTED)时,清理待确认状态。
+ */
+ public void completePendingRequest(String fromDeviceId, String toDeviceId) {
+ String key = keyOf(fromDeviceId, toDeviceId);
+ PendingRequest pending = pendingRequests.remove(key);
+ if (pending != null) {
+ if (pending.timeoutTask != null) {
+ pending.timeoutTask.cancel(false);
+ }
+ logger.info("Completed pending connection request {}", key);
+ }
+ }
+
+ private Map buildEvent(String type, String toDeviceId, String message) {
+ Map event = new ConcurrentHashMap<>();
+ event.put("type", type);
+ event.put("toDeviceId", toDeviceId);
+ event.put("payload", message);
+ return event;
+ }
+
+ private static class PendingRequest {
+ final String fromDeviceId;
+ final String toDeviceId;
+ java.util.concurrent.ScheduledFuture> timeoutTask;
+
+ PendingRequest(String fromDeviceId, String toDeviceId) {
+ this.fromDeviceId = fromDeviceId;
+ this.toDeviceId = toDeviceId;
+ }
+ }
+}
diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SessionManager.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SessionManager.java
index 9f84a00..99d1d7d 100644
--- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SessionManager.java
+++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SessionManager.java
@@ -48,6 +48,10 @@ public class SessionManager {
return session != null && session.isOpen();
}
+ public DeviceType getDeviceType(String deviceId) {
+ return deviceTypes.get(deviceId);
+ }
+
public List getDevicesByType(DeviceType type) {
List result = new ArrayList<>();
for (Map.Entry entry : deviceTypes.entrySet()) {
diff --git a/webrtc_controller_flutter/lib/controller/remote_controller.dart b/webrtc_controller_flutter/lib/controller/remote_controller.dart
index d0196ba..f086a29 100644
--- a/webrtc_controller_flutter/lib/controller/remote_controller.dart
+++ b/webrtc_controller_flutter/lib/controller/remote_controller.dart
@@ -34,6 +34,9 @@ class RemoteController {
/// 目标被控端不在线(服务器回送 TARGET_OFFLINE,用于提示用户并复位 UI)。
void Function(String message)? onTargetOffline;
+ /// 被控端拒绝了连接请求(用于提示用户并复位 UI)。
+ void Function(String message)? onConnectionRejected;
+
/// 远端视频渲染器就绪。
void Function(RTCVideoRenderer renderer)? onRemoteStream;
@@ -90,6 +93,8 @@ class RemoteController {
switch (message.type?.toUpperCase()) {
case 'ANSWER':
final payload = jsonDecode(message.payload!) as Map;
+ // 被控端已接受连接请求,进入 WebRTC 协商阶段。
+ onStatusChanged?.call('状态: 被控端已接受连接,正在建立连接...');
_webRtc?.handleAnswer(payload['sdp'] as String);
break;
case 'ICE_CANDIDATE':
@@ -101,6 +106,13 @@ class RemoteController {
onStatusChanged?.call('状态: $text');
onTargetOffline?.call(text);
break;
+ case 'CONNECTION_REJECTED':
+ case 'REQUEST_ERROR':
+ case 'REQUEST_TIMEOUT':
+ final text = message.payload ?? '连接请求失败';
+ onStatusChanged?.call('状态: $text');
+ onConnectionRejected?.call(text);
+ break;
}
}
diff --git a/webrtc_controller_flutter/lib/main.dart b/webrtc_controller_flutter/lib/main.dart
index 0045c27..77016ed 100644
--- a/webrtc_controller_flutter/lib/main.dart
+++ b/webrtc_controller_flutter/lib/main.dart
@@ -102,6 +102,9 @@ class _ControllerHomeState extends State {
_controller!.onTargetOffline = (message) {
_onTargetOffline(message);
};
+ _controller!.onConnectionRejected = (message) {
+ _onConnectionRejected(message);
+ };
_controller!.onRemoteStream = (renderer) {
setState(() => _renderer = renderer);
renderer.addListener(_onRendererUpdate);
@@ -147,6 +150,20 @@ class _ControllerHomeState extends State {
setState(() => _connected = false);
}
+ /// 被控端拒绝连接请求:提示用户并复位到连接设置面板。
+ Future _onConnectionRejected(String message) async {
+ final scaffoldCtx = _scaffoldKey.currentState?.context;
+ if (mounted && scaffoldCtx != null) {
+ ScaffoldMessenger.of(scaffoldCtx).showSnackBar(
+ SnackBar(
+ content: Text(message),
+ duration: const Duration(seconds: 3),
+ ),
+ );
+ }
+ setState(() => _connected = false);
+ }
+
Future _disconnect() async {
await _controller?.disconnect();
_controller = null;