refactor: 重构设置页面并修复屏幕共享黑屏问题
将安全验证和输入设置从主界面迁移至独立的 SettingsActivity,并允许用户手动选择模拟点击方式。 修复 Android 10+ 上 MediaProjection Token 传递失效导致黑屏的问题,通过静态变量保存授权结果并增加捕获延时与心跳保活机制。 优化多端视频编解码器协商,统一在 Offer SDP 中将 VP8/VP9 优先排列,H264 仅作兜底,提升跨端解码兼容性。
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
// binding.etDeviceId.setText(DeviceUtils.getSystemSerialNumber());
|
||||
// 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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ttstd.controlled.activity.settings;
|
||||
|
||||
import com.ttstd.controlled.base.BaseViewModel;
|
||||
|
||||
public class SettingsViewModel extends BaseViewModel {
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 Shell(input 命令,需 Root 权限)。 */
|
||||
public static final String METHOD_ROOT = "root";
|
||||
/** 无障碍服务手势(无需 Root / 系统签名)。 */
|
||||
public static final String METHOD_ACCESSIBILITY = "accessibility";
|
||||
/** 普通 Shell(input 命令,兼容性最差)。 */
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,10 @@ public class WebRtcClient {
|
||||
.createInitializationOptions();
|
||||
PeerConnectionFactory.initialize(initOptions);
|
||||
|
||||
DefaultVideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true);
|
||||
// 第三个参数 enableH264HighProfile 设为 false:H264 仅作兜底,
|
||||
// 使用 Constrained Baseline(profile-level-id 42e01f),各浏览器/桌面端均可稳定解码;
|
||||
// 主用 VP8/VP9,由 optimizeSdp 在 m=video 行优先排列。
|
||||
DefaultVideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, false);
|
||||
DefaultVideoDecoderFactory decoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext());
|
||||
|
||||
// 打印所有支持的编码格式
|
||||
@@ -541,16 +544,31 @@ 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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
168
WebRTCControlled/app/src/main/res/layout/activity_settings.xml
Normal file
168
WebRTCControlled/app/src/main/res/layout/activity_settings.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user