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(连接请求)进行统一治理。 + * + *

职责: + *

    + *
  1. 校验:OFFER 仅允许由 CONTROLLER 发往 CONTROLLED,否则拒绝并向发送方报错。
  2. + *
  3. 去重:同一主控端对同一被控端的重复 OFFER(如网络重传/重复点击)在短时间内被忽略, + * 避免被控端反复弹窗。
  4. + *
  5. 待确认跟踪:记录处于“等待被控端确认”状态的请求,收到 ANSWER(接受)或 + * CONNECTION_REJECTED(拒绝)时清理;超时未决则通知主控端请求超时。
  6. + *
+ */ +@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;