refactor: 重构设置页面并修复屏幕共享黑屏问题

将安全验证和输入设置从主界面迁移至独立的 SettingsActivity,并允许用户手动选择模拟点击方式。
修复 Android 10+ 上 MediaProjection Token 传递失效导致黑屏的问题,通过静态变量保存授权结果并增加捕获延时与心跳保活机制。
优化多端视频编解码器协商,统一在 Offer SDP 中将 VP8/VP9 优先排列,H264 仅作兜底,提升跨端解码兼容性。
This commit is contained in:
TongTongStudio
2026-07-24 17:55:44 +08:00
parent 778c37b8db
commit cdf3d5da46
14 changed files with 652 additions and 175 deletions

View File

@@ -25,6 +25,11 @@
</intent-filter>
</activity>
<activity
android:name=".activity.settings.SettingsActivity"
android:exported="false"
android:label="@string/settings_title" />
<activity
android:name=".activity.connection.ConnectionRequestActivity"
android:exported="false"

View File

@@ -30,9 +30,9 @@ import com.ttstd.controlled.R;
import com.ttstd.controlled.accessibility.AccessibilityServiceHelper;
import com.ttstd.controlled.base.BaseMvvmActivity;
import com.ttstd.controlled.databinding.ActivityMainBinding;
import com.ttstd.controlled.activity.settings.SettingsActivity;
import com.ttstd.controlled.service.ScreenCaptureService;
import com.ttstd.controlled.accessibility.KeyboardAccessibilityService;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.utils.DeviceUtils;
import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.webrtc.WebRtcClient;
@@ -84,42 +84,16 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
// 默认服务器地址
binding.etServerUrl.setText("ws://175.178.213.60:8088/ws/signal");
// 生成设备ID
// if (binding.etDeviceId.getText().toString().isEmpty()) {
// binding.etDeviceId.setText(DeviceUtils.getSystemSerialNumber());
// }
binding.btnStart.setOnClickListener(v -> startScreenSharing());
binding.btnStop.setOnClickListener(v -> stopScreenSharing());
// 安全验证:显示动态验证码,提供重新生成与固定密码设置
binding.tvDynamicCode.setText(AuthSettings.getDynamicCode(this));
binding.btnRegenerateCode.setOnClickListener(v -> {
String newCode = AuthSettings.generateDynamicCode();
AuthSettings.setDynamicCode(this, newCode);
binding.tvDynamicCode.setText(newCode);
binding.tvAuthHint.setText("动态验证码已重新生成");
});
binding.btnSavePassword.setOnClickListener(v -> {
String p1 = binding.etFixedPassword.getText().toString();
String p2 = binding.etConfirmPassword.getText().toString();
if (p1.isEmpty()) {
binding.tvAuthHint.setText("固定密码不能为空");
return;
}
if (!p1.equals(p2)) {
binding.tvAuthHint.setText("两次输入的密码不一致");
return;
}
AuthSettings.setFixedPassword(this, p1);
binding.etFixedPassword.setText("");
binding.etConfirmPassword.setText("");
binding.tvAuthHint.setText("固定密码已保存");
});
// 允许免密连接开关:默认开启;关闭后控制端必须使用验证码或密码
binding.switchAllowNoAuth.setChecked(AuthSettings.isNoAuthAllowed(this));
binding.switchAllowNoAuth.setOnCheckedChangeListener((buttonView, isChecked) -> {
AuthSettings.setNoAuthAllowed(this, isChecked);
binding.tvAuthHint.setText(isChecked ? R.string.allow_no_auth_on : R.string.allow_no_auth_off);
});
// 打开安全与输入设置页面(动态验证码 / 固定密码 / 免密连接 / 模拟点击方式)
binding.btnOpenSettings.setOnClickListener(v ->
startActivity(new Intent(this, SettingsActivity.class)));
updateUI(false);
}
@@ -195,6 +169,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
if (requestCode == REQUEST_MEDIA_PROJECTION) {
if (resultCode == RESULT_OK && data != null) {
// 设置静态授权结果(针对 Android 10+ 解决 Token 传递失效)
ScreenCaptureService.sResultCode = resultCode;
ScreenCaptureService.sResultData = data;
Intent serviceIntent = new Intent(this, ScreenCaptureService.class);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_DATA, data);

View File

@@ -0,0 +1,145 @@
package com.ttstd.controlled.activity.settings;
import android.widget.ArrayAdapter;
import android.widget.Spinner;
import android.widget.Toast;
import com.ttstd.controlled.R;
import com.ttstd.controlled.base.BaseMvvmActivity;
import com.ttstd.controlled.databinding.ActivitySettingsBinding;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.utils.InputSettings;
/**
* 安全与输入设置页:
* - 动态验证码(显示 / 重新生成)
* - 固定密码(设置并确认)
* - 允许免密连接开关
* - 模拟点击方式(自动 / 系统注入 / Root / 无障碍 / 普通 Shell
*/
public class SettingsActivity extends BaseMvvmActivity<SettingsViewModel, ActivitySettingsBinding> {
/** Spinner 选项与 InputSettings 常量的一一对应(顺序须与 string-array 一致)。 */
private static final String[] METHOD_BY_POSITION = {
InputSettings.METHOD_AUTO,
InputSettings.METHOD_SYSTEM,
InputSettings.METHOD_ROOT,
InputSettings.METHOD_ACCESSIBILITY,
InputSettings.METHOD_SHELL
};
@Override
protected int getLayoutId() {
return R.layout.activity_settings;
}
@Override
protected void initView() {
setupInputMethodSpinner();
setupAuth();
}
/** 构建并初始化「模拟点击方式」下拉框,选中当前已保存的方式。 */
private void setupInputMethodSpinner() {
Spinner spinner = binding.spinnerInputMethod;
ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(
this, R.array.input_method_entries, android.R.layout.simple_spinner_item);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinner.setAdapter(adapter);
String current = InputSettings.getInputMethod(this);
int selectIdx = 0;
for (int i = 0; i < METHOD_BY_POSITION.length; i++) {
if (METHOD_BY_POSITION[i].equals(current)) {
selectIdx = i;
break;
}
}
spinner.setSelection(selectIdx);
updateInputMethodHint(METHOD_BY_POSITION[selectIdx]);
spinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(android.widget.AdapterView<?> parent, android.view.View view,
int position, long id) {
String method = METHOD_BY_POSITION[position];
InputSettings.setInputMethod(SettingsActivity.this, method);
updateInputMethodHint(method);
binding.tvInputMethodHint.setText(R.string.input_method_saved);
}
@Override
public void onNothingSelected(android.widget.AdapterView<?> parent) {
}
});
}
/** 根据选中的方式展示说明(仅展示,不覆盖已保存状态)。 */
private void updateInputMethodHint(String method) {
int resId;
switch (method) {
case InputSettings.METHOD_SYSTEM:
resId = R.string.input_method_system;
break;
case InputSettings.METHOD_ROOT:
resId = R.string.input_method_root;
break;
case InputSettings.METHOD_ACCESSIBILITY:
resId = R.string.input_method_accessibility;
break;
case InputSettings.METHOD_SHELL:
resId = R.string.input_method_shell;
break;
case InputSettings.METHOD_AUTO:
default:
resId = R.string.input_method_auto;
break;
}
binding.tvInputMethodHint.setText(getString(R.string.input_method_desc) + "\n" + getString(resId));
}
/** 动态验证码、固定密码与免密连接开关。 */
private void setupAuth() {
binding.tvDynamicCode.setText(AuthSettings.getDynamicCode(this));
binding.btnRegenerateCode.setOnClickListener(v -> {
String newCode = AuthSettings.generateDynamicCode();
AuthSettings.setDynamicCode(this, newCode);
binding.tvDynamicCode.setText(newCode);
binding.tvAuthHint.setText("动态验证码已重新生成");
});
binding.btnSavePassword.setOnClickListener(v -> {
String p1 = binding.etFixedPassword.getText().toString();
String p2 = binding.etConfirmPassword.getText().toString();
if (p1.isEmpty()) {
binding.tvAuthHint.setText("固定密码不能为空");
return;
}
if (!p1.equals(p2)) {
binding.tvAuthHint.setText("两次输入的密码不一致");
return;
}
AuthSettings.setFixedPassword(this, p1);
binding.etFixedPassword.setText("");
binding.etConfirmPassword.setText("");
binding.tvAuthHint.setText("固定密码已保存");
});
binding.switchAllowNoAuth.setChecked(AuthSettings.isNoAuthAllowed(this));
binding.switchAllowNoAuth.setOnCheckedChangeListener((buttonView, isChecked) -> {
AuthSettings.setNoAuthAllowed(this, isChecked);
binding.tvAuthHint.setText(isChecked ? R.string.allow_no_auth_on : R.string.allow_no_auth_off);
});
}
@Override
protected void initData() {
// 无需额外数据初始化
}
@Override
public void onBackPressed() {
// 设置即时生效(已写入 SharedPreferences直接返回主页面。
Toast.makeText(this, "设置已保存", Toast.LENGTH_SHORT).show();
super.onBackPressed();
}
}

View File

@@ -0,0 +1,6 @@
package com.ttstd.controlled.activity.settings;
import com.ttstd.controlled.base.BaseViewModel;
public class SettingsViewModel extends BaseViewModel {
}

View File

@@ -36,6 +36,7 @@ import com.ttstd.controlled.input.RootShellInputUtils;
import com.ttstd.controlled.input.ShellInputUtils;
import com.ttstd.controlled.input.SystemInputUtils;
import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.utils.InputSettings;
import com.ttstd.controlled.signaling.SignalMessage;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.signaling.WebSocketClient;
@@ -62,6 +63,10 @@ public class ScreenCaptureService extends Service {
public static final String EXTRA_SERVER_URL = "server_url";
public static final String EXTRA_DEVICE_ID = "device_id";
/** 静态授权结果,解决部分 Android 10 设备跨进程/Intent 传递 Intent 时 Token 失效的问题 */
public static int sResultCode = Activity.RESULT_CANCELED;
public static Intent sResultData = null;
private ScreenCapturerAndroid screenCapturer;
private WebSocketClient wsClient;
private WebRtcClient webRtcClient;
@@ -128,17 +133,21 @@ public class ScreenCaptureService extends Service {
startForeground(NOTIFICATION_ID, notification);
}
int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED);
Intent resultData = intent.getParcelableExtra(EXTRA_RESULT_DATA);
// 优先从静态变量获取授权结果(解决 Android 10+ Intent 传递 Token 失效),若无则 fallback 到 Intent extras。
int resultCode = (sResultCode != Activity.RESULT_CANCELED)
? sResultCode : intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED);
Intent resultData = (sResultData != null)
? sResultData : intent.getParcelableExtra(EXTRA_RESULT_DATA);
String serverUrl = intent.getStringExtra(EXTRA_SERVER_URL);
deviceId = intent.getStringExtra(EXTRA_DEVICE_ID);
// 保存屏幕捕获授权结果,供"自编码"模式重新获取 MediaProjection 使用。
// 保存授权结果,供后续切换模式(如自编码模式重新申请 MediaProjection 使用。
this.resultCode = resultCode;
this.resultDataIntent = resultData;
if (resultCode != Activity.RESULT_OK || resultData == null || serverUrl == null || deviceId == null) {
Log.e(TAG, "Invalid service parameters");
Log.e(TAG, "Invalid service parameters: resultCode=" + resultCode + ", hasData=" + (resultData != null));
stopSelf();
return START_NOT_STICKY;
}
@@ -149,6 +158,7 @@ public class ScreenCaptureService extends Service {
}
isInitialized = true;
// ... 获取分辨率逻辑保持不变 ...
// 获取屏幕真实尺寸和刷新率
WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
@@ -165,6 +175,7 @@ public class ScreenCaptureService extends Service {
// 分辨率缩放逻辑:如果设备分辨率大于 1080p则等比例缩小到 1080p 以内
int captureWidth = realWidth;
int captureHeight = realHeight;
// 模拟器或低配设备建议使用 720p 甚至 480p否则软件编码 1080p 会导致卡死
int maxResolution = 1920; // 1080p 的长边
if (Math.max(realWidth, realHeight) > maxResolution) {
@@ -202,8 +213,21 @@ public class ScreenCaptureService extends Service {
inputHandler.setResolutionRequestListener(this::onRemoteResolutionRequested);
inputHandler.setStreamModeListener(this::onStreamModeRequested);
// 开启屏幕捕获,使用计算出的 capture 分辨率
startScreenCapture(resultCode, resultData, serverUrl, deviceId, captureWidth, captureHeight, fps);
// 启动心脏起搏器,解决画面静止或模拟器不推帧的问题
mainHandler.removeCallbacks(heartbeatRunnable);
mainHandler.post(heartbeatRunnable);
// Android 10+ 强烈建议在 startForeground 之后延时一点点再初始化 MediaProjection
// 确保系统已经感知到服务已切换为 FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION。
final int finalCaptureWidth = captureWidth;
final int finalCaptureHeight = captureHeight;
final int finalFps = fps;
mainHandler.postDelayed(() -> {
if (!isShuttingDown) {
startScreenCapture(resultCode, resultData, serverUrl, deviceId, finalCaptureWidth, finalCaptureHeight, finalFps);
}
}, 200);
return START_NOT_STICKY;
}
@@ -214,6 +238,34 @@ public class ScreenCaptureService extends Service {
* 3. 兜底使用 root shell 方式。
*/
private InputExecutor createInputExecutor() {
String method = InputSettings.getInputMethod(this);
switch (method) {
case InputSettings.METHOD_SYSTEM:
Log.i(TAG, "Input executor: SystemInputUtils (user selected)");
return new SystemInputUtils();
case InputSettings.METHOD_ROOT:
Log.i(TAG, "Input executor: RootShellInputUtils (user selected)");
return new RootShellInputUtils();
case InputSettings.METHOD_ACCESSIBILITY:
Log.i(TAG, "Input executor: AccessibilityInputUtils (user selected)");
return new AccessibilityInputUtils();
case InputSettings.METHOD_SHELL:
Log.i(TAG, "Input executor: ShellInputUtils (user selected)");
return new ShellInputUtils();
case InputSettings.METHOD_AUTO:
default:
return autoSelectInputExecutor();
}
}
/**
* 根据当前应用权限/环境自动选择最合适的输入执行器:
* 1. 系统签名应用优先使用隐藏 API 注入(支持精确触摸与任意按键);
* 2. 已获取 root 时使用 root shell
* 3. 已开启无障碍服务时使用 AccessibilityInputUtils无需 root/系统签名);
* 4. 兜底使用普通 shell 方式。
*/
private InputExecutor autoSelectInputExecutor() {
if (SignatureUtils.isSystemSignature(this) || SignatureUtils.isSharedSystemUid(this)) {
Log.i(TAG, "Input executor: SystemInputUtils (system signature)");
return new SystemInputUtils();
@@ -488,6 +540,8 @@ public class ScreenCaptureService extends Service {
public void onDestroy() {
super.onDestroy();
isShuttingDown = true;
sResultCode = Activity.RESULT_CANCELED;
sResultData = null;
if (instance == this) {
instance = null;
}
@@ -572,7 +626,11 @@ public class ScreenCaptureService extends Service {
// 初始化屏幕捕获
screenCapturer = new ScreenCapturerAndroid(resultData, new MediaProjection.Callback() {
@Override
public void onStop() {
super.onStop();
Log.e(TAG, "MediaProjection stopped (ScreenCapturerAndroid)");
}
});
webRtcClient.setVideoCapturer(screenCapturer, width, height, fps);
@@ -662,19 +720,19 @@ public class ScreenCaptureService extends Service {
* PASSWORD: 与被控端固定密码比对。
*/
private String verifyAuth(String authType, String authValue) {
if (authValue == null) authValue = "";
String valueToVerify = (authValue == null) ? "" : authValue;
if ("CODE".equalsIgnoreCase(authType)) {
String expected = AuthSettings.getDynamicCode(this);
if (expected == null || expected.isEmpty()) {
return "被控端未生成动态验证码";
}
return expected.equals(authValue) ? null : "动态验证码错误";
return expected.equals(valueToVerify) ? null : "动态验证码错误";
} else if ("PASSWORD".equalsIgnoreCase(authType)) {
String expected = AuthSettings.getFixedPassword(this);
if (expected == null || expected.isEmpty()) {
return "被控端未设置固定密码";
}
return expected.equals(authValue) ? null : "固定密码错误";
return expected.equals(valueToVerify) ? null : "固定密码错误";
}
return "未知的鉴权类型: " + authType;
}
@@ -798,6 +856,24 @@ public class ScreenCaptureService extends Service {
Log.i(TAG, "Sent CONNECTION_REJECTED to " + controllerId);
}
private final Runnable heartbeatRunnable = new Runnable() {
@Override
public void run() {
if (!isShuttingDown) {
updateNotificationHeartbeat();
mainHandler.postDelayed(this, 1000);
}
}
};
private int heartbeatCount = 0;
private void updateNotificationHeartbeat() {
String dots = "";
for (int i = 0; i < (heartbeatCount % 3) + 1; i++) dots += ".";
heartbeatCount++;
updateNotification("远程控制服务运行中" + dots);
}
private void handleIceCandidate(SignalMessage message) {
try {
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
@@ -826,7 +902,7 @@ public class ScreenCaptureService extends Service {
.setContentText(contentText)
.setSmallIcon(android.R.drawable.ic_menu_camera)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
.build();
}
@@ -836,7 +912,7 @@ public class ScreenCaptureService extends Service {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"屏幕共享服务",
NotificationManager.IMPORTANCE_LOW);
NotificationManager.IMPORTANCE_DEFAULT);
channel.setDescription("远程控制屏幕采集前台服务");
NotificationManager manager = getSystemService(NotificationManager.class);
manager.createNotificationChannel(channel);

View File

@@ -0,0 +1,38 @@
package com.ttstd.controlled.utils;
import android.content.Context;
import android.content.SharedPreferences;
/**
* 模拟点击方式(输入执行器)的可选配置。
* 默认「自动选择」,由 {@link com.ttstd.controlled.service.ScreenCaptureService}
* 根据当前权限/环境挑选最合适的执行器;用户也可在此手动指定。
*/
public class InputSettings {
private static final String PREF_NAME = "controlled_input_prefs";
private static final String KEY_INPUT_METHOD = "input_method";
/** 自动选择(按 系统签名 -> Root -> 无障碍 -> 普通 Shell 顺序兜底)。 */
public static final String METHOD_AUTO = "auto";
/** 系统隐藏 API 注入(需系统签名 / 共享系统 UID。 */
public static final String METHOD_SYSTEM = "system";
/** Root Shellinput 命令,需 Root 权限)。 */
public static final String METHOD_ROOT = "root";
/** 无障碍服务手势(无需 Root / 系统签名)。 */
public static final String METHOD_ACCESSIBILITY = "accessibility";
/** 普通 Shellinput 命令,兼容性最差)。 */
public static final String METHOD_SHELL = "shell";
/** 获取当前选中的模拟点击方式,默认 {@link #METHOD_AUTO}。 */
public static String getInputMethod(Context context) {
return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getString(KEY_INPUT_METHOD, METHOD_AUTO);
}
/** 设置模拟点击方式。 */
public static void setInputMethod(Context context, String method) {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_INPUT_METHOD, method == null ? METHOD_AUTO : method).apply();
}
}

View File

@@ -127,7 +127,10 @@ public class WebRtcClient {
.createInitializationOptions();
PeerConnectionFactory.initialize(initOptions);
DefaultVideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true);
// 第三个参数 enableH264HighProfile 设为 falseH264 仅作兜底,
// 使用 Constrained Baselineprofile-level-id 42e01f各浏览器/桌面端均可稳定解码;
// 主用 VP8/VP9由 optimizeSdp 在 m=video 行优先排列。
DefaultVideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, false);
DefaultVideoDecoderFactory decoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext());
// 打印所有支持的编码格式
@@ -541,15 +544,30 @@ public class WebRtcClient {
String[] lines = sdp.split("\n");
StringBuilder newSdp = new StringBuilder();
List<String> videoPayloads = new ArrayList<>();
// 识别所有视频负载类型
// 按编解码器类型分组收集 payload type。
// 重排时优先 VP8/VP9控制端/浏览器原生解码兼容性最好,桌面端与 Chromium/Linux 均可稳定硬/软解),
// H264 放最后作为兜底,避免部分环境无法解码 H264 High Profile 而黑屏。
List<String> vp8 = new ArrayList<>();
List<String> vp9 = new ArrayList<>();
List<String> h264 = new ArrayList<>();
for (String line : lines) {
String trimmedLine = line.trim();
if (trimmedLine.startsWith("a=rtpmap:") && (trimmedLine.contains("H264/90000") || trimmedLine.contains("VP8/90000") || trimmedLine.contains("VP9/90000"))) {
if (trimmedLine.startsWith("a=rtpmap:")) {
String payload = trimmedLine.split(":")[1].split(" ")[0];
videoPayloads.add(payload);
if (trimmedLine.contains("VP8/90000")) {
vp8.add(payload);
} else if (trimmedLine.contains("VP9/90000")) {
vp9.add(payload);
} else if (trimmedLine.contains("H264/90000")) {
h264.add(payload);
}
}
}
List<String> videoPayloads = new ArrayList<>();
videoPayloads.addAll(vp8);
videoPayloads.addAll(vp9);
videoPayloads.addAll(h264);
if (videoPayloads.isEmpty()) {
return sdp;
@@ -563,7 +581,6 @@ public class WebRtcClient {
String[] parts = trimmedLine.split(" ");
if (parts.length > 3) {
StringBuilder mLine = new StringBuilder(parts[0] + " " + parts[1] + " " + parts[2]);
// 将 H264 放在最前面(如果存在)
for (String payload : videoPayloads) {
mLine.append(" ").append(payload);
}
@@ -577,14 +594,7 @@ public class WebRtcClient {
newSdp.append(trimmedLine).append("\r\n");
}
} else if (trimmedLine.startsWith("a=fmtp:")) {
boolean isVideoPayload = false;
for (String payload : videoPayloads) {
if (trimmedLine.startsWith("a=fmtp:" + payload)) {
isVideoPayload = true;
break;
}
}
if (isVideoPayload) {
if (videoPayloads.contains(trimmedLine.split(":")[1].split(" ")[0])) {
String bonus = ";x-google-start-bitrate=" + START_BITRATE_KBPS
+ ";x-google-max-bitrate=" + MAX_BITRATE_KBPS
+ ";x-google-min-bitrate=" + MIN_BITRATE_KBPS;

View File

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

View File

@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.settings.SettingsActivity">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/settings_title"
android:textSize="24sp"
android:textStyle="bold" />
<!-- 安全验证:动态验证码 + 固定密码 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:text="安全验证"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="动态验证码(连接时由控制端输入):"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<TextView
android:id="@+id/tv_dynamic_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="------"
android:textSize="22sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:letterSpacing="0.1" />
<Button
android:id="@+id/btn_regenerate_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="重新生成" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="固定密码(手动设置并确认):"
android:textSize="14sp" />
<EditText
android:id="@+id/et_fixed_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="输入固定密码"
android:inputType="textPassword" />
<EditText
android:id="@+id/et_confirm_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="再次输入固定密码"
android:inputType="textPassword" />
<Button
android:id="@+id/btn_save_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="保存固定密码" />
<TextView
android:id="@+id/tv_auth_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:textSize="13sp"
android:textColor="#888888" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="24dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/allow_no_auth_label"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/allow_no_auth_desc"
android:textSize="12sp"
android:textColor="#888888" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_allow_no_auth"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- 模拟点击方式 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:text="@string/input_method_label"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/input_method_desc"
android:textSize="12sp"
android:textColor="#888888" />
<Spinner
android:id="@+id/spinner_input_method"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tv_input_method_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="#888888" />
</LinearLayout>
</ScrollView>
</layout>

View File

@@ -22,4 +22,26 @@
<string name="allow_no_auth_desc">关闭后,控制端必须使用动态验证码或固定密码才能连接</string>
<string name="allow_no_auth_on">已允许免密连接</string>
<string name="allow_no_auth_off">已关闭免密连接</string>
<!-- 设置页面 -->
<string name="settings_title">安全与输入设置</string>
<string name="btn_open_settings">安全与输入设置</string>
<!-- 模拟点击方式 -->
<string name="input_method_label">模拟点击方式</string>
<string name="input_method_desc">选择控制端触摸/按键指令在本机的执行方式,不确定时请使用「自动选择」。</string>
<string name="input_method_auto">自动选择(推荐)</string>
<string name="input_method_system">系统注入(需系统签名)</string>
<string name="input_method_root">Root Shell需 Root 权限)</string>
<string name="input_method_accessibility">无障碍服务(免 Root/签名)</string>
<string name="input_method_shell">普通 Shell兼容兜底</string>
<string name="input_method_saved">模拟点击方式已保存</string>
<string-array name="input_method_entries">
<item>@string/input_method_auto</item>
<item>@string/input_method_system</item>
<item>@string/input_method_root</item>
<item>@string/input_method_accessibility</item>
<item>@string/input_method_shell</item>
</string-array>
</resources>

View File

@@ -13,6 +13,8 @@ watch(
(stream) => {
if (videoRef.value && stream) {
videoRef.value.srcObject = stream;
// 部分浏览器在 srcObject 设置后不会自动播放(尤其是无用户手势时),需显式 play() 兜底
videoRef.value.play().catch(() => {});
hasStream.value = true;
} else {
hasStream.value = false;

View File

@@ -75,7 +75,18 @@ export class WebRtcController {
this.onIceState && this.onIceState(s);
};
this.pc.ontrack = (e) => {
if (e.streams && e.streams[0]) this.onStream && this.onStream(e.streams[0]);
// 某些浏览器/协商场景下 event.streams 可能为空,需回退到用 track 自行构造 MediaStream
// 否则 store.remoteStream 始终为空 -> 视频元素拿不到流 -> 黑屏(用户会误以为无法控制)。
let stream = e.streams && e.streams[0];
if (!stream) {
stream = new MediaStream([e.track]);
}
this.onStream && this.onStream(stream);
e.track.onunmute = () => {
if (e.track.readyState === 'live') {
this.onStream && this.onStream(stream);
}
};
};
this.pc.ondatachannel = (e) => this.setupDataChannel(e.channel);
@@ -87,12 +98,59 @@ export class WebRtcController {
this.setupDataChannel(dc);
const offer = await this.pc.createOffer();
// 与 WebRTCControlled 的 optimizeSdp 保持一致:将 m=video 行的编解码器
// 重排为 VP8/VP9 优先、H264 兜底,确保各控制端(浏览器/桌面/Chromium/Linux
// 都能稳定解码,避免 H264 High Profile 无法解码而黑屏。
offer.sdp = this.preferVideoCodecs(offer.sdp);
await this.pc.setLocalDescription(offer);
this.signaling.sendOffer(this.pc.localDescription.sdp, this.targetDeviceId, authType, authValue);
this.signaling.sendOffer(offer.sdp, this.targetDeviceId, authType, authValue);
this._startStats();
}
/**
* 重排 Offer SDP 中 m=video 行的视频编解码器顺序VP8/VP9 优先H264 兜底。
* 与 Android 被控端 WebRtcClient.optimizeSdp 的编解码优先级保持一致,
* 保证协商出的编码格式控制端一定可解码(编码/解码一致性)。
* @param {string} sdp 原始 SDP
* @returns {string} 重排后的 SDP
*/
preferVideoCodecs(sdp) {
const lines = sdp.split('\r\n');
const result = [];
// 先收集各类型视频负载的 payload type
const vp8 = [];
const vp9 = [];
const h264 = [];
for (const line of lines) {
const t = line.trim();
if (t.startsWith('a=rtpmap:')) {
const payload = t.split(':')[1].split(' ')[0];
if (t.includes('VP8/90000')) vp8.push(payload);
else if (t.includes('VP9/90000')) vp9.push(payload);
else if (t.includes('H264/90000')) h264.push(payload);
}
}
const ordered = [...vp8, ...vp9, ...h264];
if (ordered.length === 0) return sdp;
for (const line of lines) {
const t = line.trim();
if (t.startsWith('m=video')) {
const parts = t.split(' ');
if (parts.length > 3) {
const head = [parts[0], parts[1], parts[2]].join(' ');
const rest = parts.slice(3).filter((p) => !ordered.includes(p));
result.push([head, ...ordered, ...rest].join(' '));
continue;
}
}
result.push(line);
}
return result.join('\r\n');
}
setupDataChannel(dc) {
this.dataChannel = dc;
dc.onopen = () => this.onDataChannelState && this.onDataChannelState(true);
@@ -119,6 +177,11 @@ export class WebRtcController {
if (!this.pc) return;
try {
await this.pc.setRemoteDescription({ type: 'answer', sdp });
// 诊断:确认视频收发方向,便于区分“编解码器问题”与“连接方向问题”
const vt = this.pc.getTransceivers
? this.pc.getTransceivers().find((t) => t.receiver && t.receiver.track && t.receiver.track.kind === 'video')
: null;
console.info('[协商完成] video transceiver currentDirection=', vt ? vt.currentDirection : 'n/a');
} catch (e) {
this.onError && this.onError('设置远端描述(ANSWER)失败: ' + (e?.message || e));
throw e;

View File

@@ -1,5 +1,5 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.java.home=C\:/Program Files/Java/jdk-21
#org.gradle.java.home=C\:/Program Files/Java/jdk-21
kotlin.daemon.jvm.options=-Xmx4G
kotlin.compiler.execution.strategy=daemon
android.useAndroidX=true

View File

@@ -128,15 +128,82 @@ class WebRtcController {
'optional': [],
};
final offer = await _pc!.createOffer(constraints);
await _pc!.setLocalDescription(offer);
_sendOffer(offer.sdp!, authType: authType, authValue: authValue);
// 与 WebRTCControlled 的 optimizeSdp 保持一致Offer 的 m=video 行优先 VP8/VP9
// H264 兜底,确保各控制端(桌面/浏览器/Chromium/Linux都能稳定解码
// 即编码端(被控端)与解码端(控制端)协商出一致的、可解码的视频格式。
final reorderedSdp = _preferVideoCodecs(offer.sdp!);
final offerWithPref = RTCSessionDescription(reorderedSdp, offer.type);
await _pc!.setLocalDescription(offerWithPref);
_sendOffer(reorderedSdp, authType: authType, authValue: authValue);
}
/// 重排 Offer SDP 中 `m=video` 行的视频编解码器顺序VP8/VP9 优先H264 兜底。
///
/// 与 Android 被控端 [WebRtcClient.optimizeSdp] 的编解码优先级保持一致,
/// 保证协商出的编码格式控制端一定可解码(编码/解码一致性)。
String _preferVideoCodecs(String sdp) {
final lines = sdp.split('\r\n');
final vp8 = <String>[];
final vp9 = <String>[];
final h264 = <String>[];
for (final line in lines) {
final t = line.trim();
if (t.startsWith('a=rtpmap:')) {
final payload = t.split(':')[1].split(' ')[0];
if (t.contains('VP8/90000')) {
vp8.add(payload);
} else if (t.contains('VP9/90000')) {
vp9.add(payload);
} else if (t.contains('H264/90000')) {
h264.add(payload);
}
}
}
final ordered = <String>[...vp8, ...vp9, ...h264];
if (ordered.isEmpty) return sdp;
final result = <String>[];
for (final line in lines) {
final t = line.trim();
if (t.startsWith('m=video')) {
final parts = t.split(' ');
if (parts.length > 3) {
final head = '${parts[0]} ${parts[1]} ${parts[2]}';
final rest = parts
.skip(3)
.where((p) => !ordered.contains(p))
.toList();
result.add([head, ...ordered, ...rest].join(' '));
continue;
}
}
result.add(line);
}
return result.join('\r\n');
}
void _onTrack(RTCTrackEvent event) {
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
renderer.srcObject = event.streams[0];
onRemoteStream?.call(renderer);
if (event.track.kind != 'video') return;
_bindRemoteVideo(event);
}
/// 绑定远端视频轨道到渲染器。
///
/// 与 Web 端 WebRtcController.ontrack 的兜底逻辑保持一致:
/// 某些平台/协商场景下Unified Plan + recvonly`event.streams` 可能为空,
/// 此时必须用 `event.track` 自行构造 MediaStream否则 renderer.srcObject
/// 为空 -> 控制端拿不到画面(黑屏/一直显示“等待画面”),但控制通道不受影响。
Future<void> _bindRemoteVideo(RTCTrackEvent event) async {
MediaStream stream;
if (event.streams.isNotEmpty) {
stream = event.streams[0];
} else {
// event.streams 为空:用 track 自行构造 MediaStream。
stream = await createLocalMediaStream('remoteVideo');
await stream.addTrack(event.track);
}
renderer.srcObject = stream;
onRemoteStream?.call(renderer);
}
void _onDataChannel(RTCDataChannel channel) {