feat: 增加请求弹窗和拒绝说明

This commit is contained in:
2026-07-15 21:40:00 +08:00
parent 839a980b45
commit f1457e68eb
13 changed files with 645 additions and 5 deletions

View File

@@ -25,6 +25,11 @@
</intent-filter>
</activity>
<activity
android:name=".activity.connection.ConnectionRequestActivity"
android:exported="false"
android:theme="@style/Theme.MaterialComponents.DayNight.Dialog.Alert" />
<service
android:name=".service.ScreenCaptureService"
android:exported="false"

View File

@@ -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();
}
}
}

View File

@@ -25,7 +25,8 @@ import com.ttstd.controlled.webrtc.WebRtcClient;
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 int REQUEST_MEDIA_PROJECTION = 1001;
@@ -42,6 +43,8 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
ScreenCaptureService.LocalBinder binder = (ScreenCaptureService.LocalBinder) service;
screenCaptureService = binder.getService();
isBound = true;
// 注册状态监听,使界面能感知信令断开/控制端断开并同步显示。
screenCaptureService.setStateListener(MainActivity.this);
setupLocalPreview();
}
@@ -135,6 +138,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
private void stopScreenSharing() {
if (isBound) {
if (screenCaptureService != null) {
screenCaptureService.setStateListener(null);
}
unbindService(serviceConnection);
isBound = false;
}
@@ -172,6 +178,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
protected void onDestroy() {
super.onDestroy();
if (isBound) {
if (screenCaptureService != null) {
screenCaptureService.setStateListener(null);
}
unbindService(serviceConnection);
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) {
isServiceRunning = running;
binding.btnStart.setEnabled(!running);

View File

@@ -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) {

View File

@@ -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<PeerConnection.IceServer> 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

View File

@@ -1,4 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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>