feat: 增加请求弹窗和拒绝说明
This commit is contained in:
@@ -25,6 +25,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".activity.connection.ConnectionRequestActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.MaterialComponents.DayNight.Dialog.Alert" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".service.ScreenCaptureService"
|
android:name=".service.ScreenCaptureService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.ttstd.controlled.activity.connection;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.ttstd.controlled.R;
|
||||||
|
import com.ttstd.controlled.service.ScreenCaptureService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远程连接请求确认弹窗(Dialog 主题 Activity)。
|
||||||
|
* 由 {@link ScreenCaptureService#handleOffer} 在被控端收到 OFFER 时启动,
|
||||||
|
* 用户可选择「接受」或「拒绝」。若 60 秒内无操作,则视为拒绝以释放待处理状态。
|
||||||
|
*/
|
||||||
|
public class ConnectionRequestActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private static final String TAG = "ConnectionRequest";
|
||||||
|
|
||||||
|
/** 启动本 Activity 时携带的发起方(控制端)设备 ID。 */
|
||||||
|
public static final String EXTRA_CONTROLLER_ID = "controller_id";
|
||||||
|
|
||||||
|
/** 无响应自动拒绝的超时时间(毫秒)。 */
|
||||||
|
private static final long AUTO_REJECT_TIMEOUT = 60_000L;
|
||||||
|
|
||||||
|
private AlertDialog dialog;
|
||||||
|
private final Handler timeoutHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private boolean finished = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
String controllerId = getIntent() != null
|
||||||
|
? getIntent().getStringExtra(EXTRA_CONTROLLER_ID)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 服务实例不存在或缺少必要参数时,直接结束,不做任何连接处理。
|
||||||
|
if (ScreenCaptureService.getInstance() == null || controllerId == null) {
|
||||||
|
Log.w(TAG, "Service not ready or controllerId missing, finishing dialog");
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
||||||
|
builder.setTitle(R.string.connection_request_title);
|
||||||
|
builder.setMessage(getString(R.string.connection_request_message, controllerId));
|
||||||
|
builder.setCancelable(false);
|
||||||
|
|
||||||
|
builder.setPositiveButton(R.string.connection_accept, (d, which) -> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@ import com.ttstd.controlled.webrtc.WebRtcClient;
|
|||||||
|
|
||||||
import org.webrtc.RendererCommon;
|
import org.webrtc.RendererCommon;
|
||||||
|
|
||||||
public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBinding> {
|
public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBinding>
|
||||||
|
implements ScreenCaptureService.ServiceStateListener {
|
||||||
|
|
||||||
private static final String TAG = "ControlledMain";
|
private static final String TAG = "ControlledMain";
|
||||||
private static final int REQUEST_MEDIA_PROJECTION = 1001;
|
private static final int REQUEST_MEDIA_PROJECTION = 1001;
|
||||||
@@ -42,6 +43,8 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
ScreenCaptureService.LocalBinder binder = (ScreenCaptureService.LocalBinder) service;
|
ScreenCaptureService.LocalBinder binder = (ScreenCaptureService.LocalBinder) service;
|
||||||
screenCaptureService = binder.getService();
|
screenCaptureService = binder.getService();
|
||||||
isBound = true;
|
isBound = true;
|
||||||
|
// 注册状态监听,使界面能感知信令断开/控制端断开并同步显示。
|
||||||
|
screenCaptureService.setStateListener(MainActivity.this);
|
||||||
setupLocalPreview();
|
setupLocalPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +138,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
|
|
||||||
private void stopScreenSharing() {
|
private void stopScreenSharing() {
|
||||||
if (isBound) {
|
if (isBound) {
|
||||||
|
if (screenCaptureService != null) {
|
||||||
|
screenCaptureService.setStateListener(null);
|
||||||
|
}
|
||||||
unbindService(serviceConnection);
|
unbindService(serviceConnection);
|
||||||
isBound = false;
|
isBound = false;
|
||||||
}
|
}
|
||||||
@@ -172,6 +178,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
protected void onDestroy() {
|
protected void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
if (isBound) {
|
if (isBound) {
|
||||||
|
if (screenCaptureService != null) {
|
||||||
|
screenCaptureService.setStateListener(null);
|
||||||
|
}
|
||||||
unbindService(serviceConnection);
|
unbindService(serviceConnection);
|
||||||
isBound = false;
|
isBound = false;
|
||||||
}
|
}
|
||||||
@@ -181,6 +190,25 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ScreenCaptureService.ServiceStateListener 实现 ----
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSignalDisconnected() {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
// 信令服务器断开:刷新为已停止状态,并解绑/停止服务释放资源。
|
||||||
|
updateUI(false);
|
||||||
|
stopScreenSharing();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onControllerDisconnected(String name) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
// 控制端断开但信令仍在线:更新状态文本提示,保持可重新连接。
|
||||||
|
binding.tvStatus.setText("状态: 控制端(" + name + ")已断开");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void updateUI(boolean running) {
|
private void updateUI(boolean running) {
|
||||||
isServiceRunning = running;
|
isServiceRunning = running;
|
||||||
binding.btnStart.setEnabled(!running);
|
binding.btnStart.setEnabled(!running);
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import android.media.projection.MediaProjection;
|
|||||||
import android.media.projection.MediaProjectionManager;
|
import android.media.projection.MediaProjectionManager;
|
||||||
import android.os.Binder;
|
import android.os.Binder;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.widget.Toast;
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
@@ -21,6 +25,8 @@ import android.view.WindowManager;
|
|||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.core.app.NotificationCompat;
|
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.activity.main.MainActivity;
|
||||||
import com.ttstd.controlled.input.InputCommandHandler;
|
import com.ttstd.controlled.input.InputCommandHandler;
|
||||||
import com.ttstd.controlled.signaling.SignalMessage;
|
import com.ttstd.controlled.signaling.SignalMessage;
|
||||||
@@ -53,9 +59,30 @@ public class ScreenCaptureService extends Service {
|
|||||||
private EglBase eglBase;
|
private EglBase eglBase;
|
||||||
private final Gson gson = new Gson();
|
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
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
|
instance = this;
|
||||||
createNotificationChannel();
|
createNotificationChannel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +104,7 @@ public class ScreenCaptureService extends Service {
|
|||||||
int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED);
|
int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED);
|
||||||
Intent resultData = intent.getParcelableExtra(EXTRA_RESULT_DATA);
|
Intent resultData = intent.getParcelableExtra(EXTRA_RESULT_DATA);
|
||||||
String serverUrl = intent.getStringExtra(EXTRA_SERVER_URL);
|
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) {
|
if (resultCode != Activity.RESULT_OK || resultData == null || serverUrl == null || deviceId == null) {
|
||||||
Log.e(TAG, "Invalid service parameters");
|
Log.e(TAG, "Invalid service parameters");
|
||||||
@@ -151,9 +178,29 @@ public class ScreenCaptureService extends Service {
|
|||||||
return webRtcClient;
|
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
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
|
isShuttingDown = true;
|
||||||
|
if (instance == this) {
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
if (screenCapturer != null) {
|
if (screenCapturer != null) {
|
||||||
screenCapturer.stopCapture();
|
screenCapturer.stopCapture();
|
||||||
screenCapturer.dispose();
|
screenCapturer.dispose();
|
||||||
@@ -192,11 +239,15 @@ public class ScreenCaptureService extends Service {
|
|||||||
@Override
|
@Override
|
||||||
public void onDisconnected() {
|
public void onDisconnected() {
|
||||||
Log.i(TAG, "Disconnected from signal server");
|
Log.i(TAG, "Disconnected from signal server");
|
||||||
|
mainHandler.post(() -> onSignalServerDisconnected());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(String error) {
|
public void onError(String error) {
|
||||||
Log.e(TAG, "WebSocket error: " + error);
|
Log.e(TAG, "WebSocket error: " + error);
|
||||||
|
if(TextUtils.isEmpty(error)){
|
||||||
|
mainHandler.post(() -> onSignalServerDisconnected());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -210,6 +261,9 @@ public class ScreenCaptureService extends Service {
|
|||||||
webRtcClient = new WebRtcClient(this, wsClient, deviceId);
|
webRtcClient = new WebRtcClient(this, wsClient, deviceId);
|
||||||
webRtcClient.initialize(eglBase);
|
webRtcClient.initialize(eglBase);
|
||||||
webRtcClient.setInputCallback(commandBytes -> inputHandler.handleCommand(commandBytes));
|
webRtcClient.setInputCallback(commandBytes -> inputHandler.handleCommand(commandBytes));
|
||||||
|
// 注册远程控制断开回调:控制端断线时提示其用户名。
|
||||||
|
webRtcClient.setRemoteDisconnectCallback(controllerId ->
|
||||||
|
mainHandler.post(() -> onControllerDisconnected(controllerId)));
|
||||||
|
|
||||||
// 初始化屏幕捕获
|
// 初始化屏幕捕获
|
||||||
screenCapturer = new ScreenCapturerAndroid(resultData, new MediaProjection.Callback() {
|
screenCapturer = new ScreenCapturerAndroid(resultData, new MediaProjection.Callback() {
|
||||||
@@ -234,18 +288,123 @@ public class ScreenCaptureService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleOffer(SignalMessage message) {
|
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 offerSdp = null;
|
||||||
|
String controllerName = null;
|
||||||
try {
|
try {
|
||||||
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
|
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
|
||||||
offerSdp = payload.get("sdp").getAsString();
|
offerSdp = payload.get("sdp").getAsString();
|
||||||
|
// 控制端若在 OFFER 中携带用户名则使用,否则回退到设备 ID。
|
||||||
|
if (payload.has("username") && !payload.get("username").isJsonNull()) {
|
||||||
|
controllerName = payload.get("username").getAsString();
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error parsing offer SDP", e);
|
Log.e(TAG, "Error parsing offer SDP", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String controllerId = message.getFromDeviceId();
|
String controllerId = message.getFromDeviceId();
|
||||||
|
// 暂存请求,弹出确认对话框,交由用户决定是否接受。
|
||||||
|
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);
|
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) {
|
private void handleIceCandidate(SignalMessage message) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -65,10 +65,18 @@ public class WebRtcClient {
|
|||||||
|
|
||||||
private InputCommandCallback inputCallback;
|
private InputCommandCallback inputCallback;
|
||||||
|
|
||||||
|
private RemoteDisconnectCallback remoteDisconnectCallback;
|
||||||
|
private boolean remoteDisconnectNotified = false;
|
||||||
|
|
||||||
public interface InputCommandCallback {
|
public interface InputCommandCallback {
|
||||||
void onCommandReceived(byte[] data);
|
void onCommandReceived(byte[] data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 远程控制端(PeerConnection / DataChannel)断开时的回调。 */
|
||||||
|
public interface RemoteDisconnectCallback {
|
||||||
|
void onRemoteDisconnected(String controllerId);
|
||||||
|
}
|
||||||
|
|
||||||
public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) {
|
public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.wsClient = wsClient;
|
this.wsClient = wsClient;
|
||||||
@@ -79,6 +87,18 @@ public class WebRtcClient {
|
|||||||
this.inputCallback = callback;
|
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) {
|
public void initialize(EglBase eglBase) {
|
||||||
this.eglBase = eglBase;
|
this.eglBase = eglBase;
|
||||||
PeerConnectionFactory.InitializationOptions initOptions =
|
PeerConnectionFactory.InitializationOptions initOptions =
|
||||||
@@ -141,6 +161,7 @@ public class WebRtcClient {
|
|||||||
closeCurrentConnection();
|
closeCurrentConnection();
|
||||||
}
|
}
|
||||||
this.currentControllerId = controllerId;
|
this.currentControllerId = controllerId;
|
||||||
|
this.remoteDisconnectNotified = false;
|
||||||
|
|
||||||
List<PeerConnection.IceServer> iceServers = new ArrayList<>();
|
List<PeerConnection.IceServer> iceServers = new ArrayList<>();
|
||||||
// 建议:如果你有 TURN 服务器,请务必在此处添加,例如:
|
// 建议:如果你有 TURN 服务器,请务必在此处添加,例如:
|
||||||
@@ -171,6 +192,11 @@ public class WebRtcClient {
|
|||||||
Log.d(TAG, "ICE connection state: " + newState);
|
Log.d(TAG, "ICE connection state: " + newState);
|
||||||
if (newState == PeerConnection.IceConnectionState.CONNECTED) {
|
if (newState == PeerConnection.IceConnectionState.CONNECTED) {
|
||||||
refreshVideoSource();
|
refreshVideoSource();
|
||||||
|
} else if (newState == PeerConnection.IceConnectionState.DISCONNECTED
|
||||||
|
|| newState == PeerConnection.IceConnectionState.FAILED
|
||||||
|
|| newState == PeerConnection.IceConnectionState.CLOSED) {
|
||||||
|
// 远程控制端断开(网络中断 / 主动退出),通知上层提示用户。
|
||||||
|
notifyRemoteDisconnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +345,9 @@ public class WebRtcClient {
|
|||||||
@Override
|
@Override
|
||||||
public void onStateChange() {
|
public void onStateChange() {
|
||||||
Log.d(TAG, "DataChannel state: " + dc.state());
|
Log.d(TAG, "DataChannel state: " + dc.state());
|
||||||
|
if (dc.state() == DataChannel.State.CLOSED) {
|
||||||
|
notifyRemoteDisconnected();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">WebRTC被控端</string>
|
<string name="app_name">WebRTC被控端</string>
|
||||||
|
<string name="connection_request_title">远程连接请求</string>
|
||||||
|
<string name="connection_request_message">设备 %s 请求远程控制您的屏幕,是否允许?</string>
|
||||||
|
<string name="connection_accept">接受</string>
|
||||||
|
<string name="connection_reject">拒绝</string>
|
||||||
|
<string name="signal_disconnected_message">与信令服务器断开,屏幕共享已停止</string>
|
||||||
|
<string name="controller_disconnected_message">控制端(%s)已断开远程控制</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -289,6 +289,13 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
case "TARGET_OFFLINE":
|
case "TARGET_OFFLINE":
|
||||||
handleTargetOffline(message);
|
handleTargetOffline(message);
|
||||||
break;
|
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");
|
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) {
|
private void handleAnswer(SignalMessage message) {
|
||||||
try {
|
try {
|
||||||
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
|
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
|
||||||
String sdp = payload.get("sdp").getAsString();
|
String sdp = payload.get("sdp").getAsString();
|
||||||
if (webRtcClient != null) {
|
if (webRtcClient != null) {
|
||||||
|
// 被控端已接受连接请求,进入 WebRTC 协商阶段。
|
||||||
|
runOnUiThread(() -> tvStatus.setText("状态: 被控端已接受连接,正在建立连接..."));
|
||||||
webRtcClient.handleAnswer(sdp);
|
webRtcClient.handleAnswer(sdp);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ public class WebRtcClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void sendOffer(String sdp, String controlledId) {
|
private void sendOffer(String sdp, String controlledId) {
|
||||||
|
Log.e(TAG, "sendOffer: " );
|
||||||
SignalMessage msg = new SignalMessage();
|
SignalMessage msg = new SignalMessage();
|
||||||
msg.setType("OFFER");
|
msg.setType("OFFER");
|
||||||
msg.setFromDeviceId(deviceId);
|
msg.setFromDeviceId(deviceId);
|
||||||
@@ -299,6 +300,7 @@ public class WebRtcClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void sendIceCandidate(IceCandidate candidate, String controlledId) {
|
private void sendIceCandidate(IceCandidate candidate, String controlledId) {
|
||||||
|
Log.e(TAG, "sendIceCandidate: " );
|
||||||
SignalMessage msg = new SignalMessage();
|
SignalMessage msg = new SignalMessage();
|
||||||
msg.setType("ICE_CANDIDATE");
|
msg.setType("ICE_CANDIDATE");
|
||||||
msg.setFromDeviceId(deviceId);
|
msg.setFromDeviceId(deviceId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.ttstd.signaling.handler;
|
package com.ttstd.signaling.handler;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.ttstd.signaling.manager.ConnectionRequestManager;
|
||||||
import com.ttstd.signaling.manager.SessionManager;
|
import com.ttstd.signaling.manager.SessionManager;
|
||||||
import com.ttstd.signaling.model.DeviceType;
|
import com.ttstd.signaling.model.DeviceType;
|
||||||
import com.ttstd.signaling.model.SignalMessage;
|
import com.ttstd.signaling.model.SignalMessage;
|
||||||
@@ -24,9 +25,24 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
private final SessionManager sessionManager;
|
private final SessionManager sessionManager;
|
||||||
|
private final ConnectionRequestManager connectionRequestManager;
|
||||||
|
|
||||||
public SignalWebSocketHandler(SessionManager sessionManager) {
|
public SignalWebSocketHandler(SessionManager sessionManager, ConnectionRequestManager connectionRequestManager) {
|
||||||
this.sessionManager = sessionManager;
|
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
|
@Override
|
||||||
@@ -56,10 +72,21 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
handleDeviceList(session, signalMessage);
|
handleDeviceList(session, signalMessage);
|
||||||
break;
|
break;
|
||||||
case "OFFER":
|
case "OFFER":
|
||||||
|
// 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发
|
||||||
|
handleConnectionRequest(signalMessage);
|
||||||
|
break;
|
||||||
case "ANSWER":
|
case "ANSWER":
|
||||||
// 关键信令:目标不在线时回送 TARGET_OFFLINE 提醒发送方
|
// 被控端已接受:清理待确认状态并转发 Answer 给主控端
|
||||||
|
connectionRequestManager.completePendingRequest(
|
||||||
|
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
|
||||||
forwardMessage(signalMessage, true);
|
forwardMessage(signalMessage, true);
|
||||||
break;
|
break;
|
||||||
|
case "CONNECTION_REJECTED":
|
||||||
|
// 被控端已拒绝:清理待确认状态并转发拒绝通知给主控端
|
||||||
|
connectionRequestManager.completePendingRequest(
|
||||||
|
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
|
||||||
|
forwardMessage(signalMessage, false);
|
||||||
|
break;
|
||||||
case "ICE_CANDIDATE":
|
case "ICE_CANDIDATE":
|
||||||
case "CONTROL_COMMAND":
|
case "CONTROL_COMMAND":
|
||||||
// 普通信令:目标不在线时静默丢弃,不返回提示
|
// 普通信令:目标不在线时静默丢弃,不返回提示
|
||||||
@@ -126,6 +153,44 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
sendToSession(session, response);
|
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<String, Object> 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) {
|
private void forwardMessage(SignalMessage message, boolean notifyOffline) {
|
||||||
String toDeviceId = message.getToDeviceId();
|
String toDeviceId = message.getToDeviceId();
|
||||||
if (toDeviceId == null) {
|
if (toDeviceId == null) {
|
||||||
|
|||||||
@@ -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(连接请求)进行统一治理。
|
||||||
|
*
|
||||||
|
* <p>职责:
|
||||||
|
* <ol>
|
||||||
|
* <li><b>校验</b>:OFFER 仅允许由 CONTROLLER 发往 CONTROLLED,否则拒绝并向发送方报错。</li>
|
||||||
|
* <li><b>去重</b>:同一主控端对同一被控端的重复 OFFER(如网络重传/重复点击)在短时间内被忽略,
|
||||||
|
* 避免被控端反复弹窗。</li>
|
||||||
|
* <li><b>待确认跟踪</b>:记录处于“等待被控端确认”状态的请求,收到 ANSWER(接受)或
|
||||||
|
* CONNECTION_REJECTED(拒绝)时清理;超时未决则通知主控端请求超时。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@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<String, Long> lastOfferTime = new ConcurrentHashMap<>();
|
||||||
|
/** 处于“等待被控端确认”状态的请求,键同上。 */
|
||||||
|
private final Map<String, PendingRequest> 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<String, Object> buildEvent(String type, String toDeviceId, String message) {
|
||||||
|
Map<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,10 @@ public class SessionManager {
|
|||||||
return session != null && session.isOpen();
|
return session != null && session.isOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DeviceType getDeviceType(String deviceId) {
|
||||||
|
return deviceTypes.get(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
public List<String> getDevicesByType(DeviceType type) {
|
public List<String> getDevicesByType(DeviceType type) {
|
||||||
List<String> result = new ArrayList<>();
|
List<String> result = new ArrayList<>();
|
||||||
for (Map.Entry<String, DeviceType> entry : deviceTypes.entrySet()) {
|
for (Map.Entry<String, DeviceType> entry : deviceTypes.entrySet()) {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ class RemoteController {
|
|||||||
/// 目标被控端不在线(服务器回送 TARGET_OFFLINE,用于提示用户并复位 UI)。
|
/// 目标被控端不在线(服务器回送 TARGET_OFFLINE,用于提示用户并复位 UI)。
|
||||||
void Function(String message)? onTargetOffline;
|
void Function(String message)? onTargetOffline;
|
||||||
|
|
||||||
|
/// 被控端拒绝了连接请求(用于提示用户并复位 UI)。
|
||||||
|
void Function(String message)? onConnectionRejected;
|
||||||
|
|
||||||
/// 远端视频渲染器就绪。
|
/// 远端视频渲染器就绪。
|
||||||
void Function(RTCVideoRenderer renderer)? onRemoteStream;
|
void Function(RTCVideoRenderer renderer)? onRemoteStream;
|
||||||
|
|
||||||
@@ -90,6 +93,8 @@ class RemoteController {
|
|||||||
switch (message.type?.toUpperCase()) {
|
switch (message.type?.toUpperCase()) {
|
||||||
case 'ANSWER':
|
case 'ANSWER':
|
||||||
final payload = jsonDecode(message.payload!) as Map<String, dynamic>;
|
final payload = jsonDecode(message.payload!) as Map<String, dynamic>;
|
||||||
|
// 被控端已接受连接请求,进入 WebRTC 协商阶段。
|
||||||
|
onStatusChanged?.call('状态: 被控端已接受连接,正在建立连接...');
|
||||||
_webRtc?.handleAnswer(payload['sdp'] as String);
|
_webRtc?.handleAnswer(payload['sdp'] as String);
|
||||||
break;
|
break;
|
||||||
case 'ICE_CANDIDATE':
|
case 'ICE_CANDIDATE':
|
||||||
@@ -101,6 +106,13 @@ class RemoteController {
|
|||||||
onStatusChanged?.call('状态: $text');
|
onStatusChanged?.call('状态: $text');
|
||||||
onTargetOffline?.call(text);
|
onTargetOffline?.call(text);
|
||||||
break;
|
break;
|
||||||
|
case 'CONNECTION_REJECTED':
|
||||||
|
case 'REQUEST_ERROR':
|
||||||
|
case 'REQUEST_TIMEOUT':
|
||||||
|
final text = message.payload ?? '连接请求失败';
|
||||||
|
onStatusChanged?.call('状态: $text');
|
||||||
|
onConnectionRejected?.call(text);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ class _ControllerHomeState extends State<ControllerHome> {
|
|||||||
_controller!.onTargetOffline = (message) {
|
_controller!.onTargetOffline = (message) {
|
||||||
_onTargetOffline(message);
|
_onTargetOffline(message);
|
||||||
};
|
};
|
||||||
|
_controller!.onConnectionRejected = (message) {
|
||||||
|
_onConnectionRejected(message);
|
||||||
|
};
|
||||||
_controller!.onRemoteStream = (renderer) {
|
_controller!.onRemoteStream = (renderer) {
|
||||||
setState(() => _renderer = renderer);
|
setState(() => _renderer = renderer);
|
||||||
renderer.addListener(_onRendererUpdate);
|
renderer.addListener(_onRendererUpdate);
|
||||||
@@ -147,6 +150,20 @@ class _ControllerHomeState extends State<ControllerHome> {
|
|||||||
setState(() => _connected = false);
|
setState(() => _connected = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 被控端拒绝连接请求:提示用户并复位到连接设置面板。
|
||||||
|
Future<void> _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<void> _disconnect() async {
|
Future<void> _disconnect() async {
|
||||||
await _controller?.disconnect();
|
await _controller?.disconnect();
|
||||||
_controller = null;
|
_controller = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user