refactor: 重构设置页面并修复屏幕共享黑屏问题
将安全验证和输入设置从主界面迁移至独立的 SettingsActivity,并允许用户手动选择模拟点击方式。 修复 Android 10+ 上 MediaProjection Token 传递失效导致黑屏的问题,通过静态变量保存授权结果并增加捕获延时与心跳保活机制。 优化多端视频编解码器协商,统一在 Offer SDP 中将 VP8/VP9 优先排列,H264 仅作兜底,提升跨端解码兼容性。
This commit is contained in:
@@ -25,6 +25,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".activity.settings.SettingsActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/settings_title" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activity.connection.ConnectionRequestActivity"
|
android:name=".activity.connection.ConnectionRequestActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ import com.ttstd.controlled.R;
|
|||||||
import com.ttstd.controlled.accessibility.AccessibilityServiceHelper;
|
import com.ttstd.controlled.accessibility.AccessibilityServiceHelper;
|
||||||
import com.ttstd.controlled.base.BaseMvvmActivity;
|
import com.ttstd.controlled.base.BaseMvvmActivity;
|
||||||
import com.ttstd.controlled.databinding.ActivityMainBinding;
|
import com.ttstd.controlled.databinding.ActivityMainBinding;
|
||||||
|
import com.ttstd.controlled.activity.settings.SettingsActivity;
|
||||||
import com.ttstd.controlled.service.ScreenCaptureService;
|
import com.ttstd.controlled.service.ScreenCaptureService;
|
||||||
import com.ttstd.controlled.accessibility.KeyboardAccessibilityService;
|
import com.ttstd.controlled.accessibility.KeyboardAccessibilityService;
|
||||||
import com.ttstd.controlled.utils.AuthSettings;
|
|
||||||
import com.ttstd.controlled.utils.DeviceUtils;
|
import com.ttstd.controlled.utils.DeviceUtils;
|
||||||
import com.ttstd.controlled.utils.SignatureUtils;
|
import com.ttstd.controlled.utils.SignatureUtils;
|
||||||
import com.ttstd.controlled.webrtc.WebRtcClient;
|
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");
|
binding.etServerUrl.setText("ws://175.178.213.60:8088/ws/signal");
|
||||||
// 生成设备ID
|
// 生成设备ID
|
||||||
// binding.etDeviceId.setText(DeviceUtils.getSystemSerialNumber());
|
// if (binding.etDeviceId.getText().toString().isEmpty()) {
|
||||||
|
// binding.etDeviceId.setText(DeviceUtils.getSystemSerialNumber());
|
||||||
|
// }
|
||||||
|
|
||||||
binding.btnStart.setOnClickListener(v -> startScreenSharing());
|
binding.btnStart.setOnClickListener(v -> startScreenSharing());
|
||||||
binding.btnStop.setOnClickListener(v -> stopScreenSharing());
|
binding.btnStop.setOnClickListener(v -> stopScreenSharing());
|
||||||
|
|
||||||
// 安全验证:显示动态验证码,提供重新生成与固定密码设置
|
// 打开安全与输入设置页面(动态验证码 / 固定密码 / 免密连接 / 模拟点击方式)
|
||||||
binding.tvDynamicCode.setText(AuthSettings.getDynamicCode(this));
|
binding.btnOpenSettings.setOnClickListener(v ->
|
||||||
binding.btnRegenerateCode.setOnClickListener(v -> {
|
startActivity(new Intent(this, SettingsActivity.class)));
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
updateUI(false);
|
updateUI(false);
|
||||||
}
|
}
|
||||||
@@ -195,6 +169,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
|
|
||||||
if (requestCode == REQUEST_MEDIA_PROJECTION) {
|
if (requestCode == REQUEST_MEDIA_PROJECTION) {
|
||||||
if (resultCode == RESULT_OK && data != null) {
|
if (resultCode == RESULT_OK && data != null) {
|
||||||
|
// 设置静态授权结果(针对 Android 10+ 解决 Token 传递失效)
|
||||||
|
ScreenCaptureService.sResultCode = resultCode;
|
||||||
|
ScreenCaptureService.sResultData = data;
|
||||||
|
|
||||||
Intent serviceIntent = new Intent(this, ScreenCaptureService.class);
|
Intent serviceIntent = new Intent(this, ScreenCaptureService.class);
|
||||||
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode);
|
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode);
|
||||||
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_DATA, data);
|
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.ShellInputUtils;
|
||||||
import com.ttstd.controlled.input.SystemInputUtils;
|
import com.ttstd.controlled.input.SystemInputUtils;
|
||||||
import com.ttstd.controlled.utils.SignatureUtils;
|
import com.ttstd.controlled.utils.SignatureUtils;
|
||||||
|
import com.ttstd.controlled.utils.InputSettings;
|
||||||
import com.ttstd.controlled.signaling.SignalMessage;
|
import com.ttstd.controlled.signaling.SignalMessage;
|
||||||
import com.ttstd.controlled.utils.AuthSettings;
|
import com.ttstd.controlled.utils.AuthSettings;
|
||||||
import com.ttstd.controlled.signaling.WebSocketClient;
|
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_SERVER_URL = "server_url";
|
||||||
public static final String EXTRA_DEVICE_ID = "device_id";
|
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 ScreenCapturerAndroid screenCapturer;
|
||||||
private WebSocketClient wsClient;
|
private WebSocketClient wsClient;
|
||||||
private WebRtcClient webRtcClient;
|
private WebRtcClient webRtcClient;
|
||||||
@@ -128,17 +133,21 @@ public class ScreenCaptureService extends Service {
|
|||||||
startForeground(NOTIFICATION_ID, notification);
|
startForeground(NOTIFICATION_ID, notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED);
|
// 优先从静态变量获取授权结果(解决 Android 10+ Intent 传递 Token 失效),若无则 fallback 到 Intent extras。
|
||||||
Intent resultData = intent.getParcelableExtra(EXTRA_RESULT_DATA);
|
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);
|
String serverUrl = intent.getStringExtra(EXTRA_SERVER_URL);
|
||||||
deviceId = intent.getStringExtra(EXTRA_DEVICE_ID);
|
deviceId = intent.getStringExtra(EXTRA_DEVICE_ID);
|
||||||
|
|
||||||
// 保存屏幕捕获授权结果,供"自编码"模式重新获取 MediaProjection 使用。
|
// 保存授权结果,供后续切换模式(如自编码模式)重新申请 MediaProjection 时使用。
|
||||||
this.resultCode = resultCode;
|
this.resultCode = resultCode;
|
||||||
this.resultDataIntent = resultData;
|
this.resultDataIntent = resultData;
|
||||||
|
|
||||||
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: resultCode=" + resultCode + ", hasData=" + (resultData != null));
|
||||||
stopSelf();
|
stopSelf();
|
||||||
return START_NOT_STICKY;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
@@ -149,6 +158,7 @@ public class ScreenCaptureService extends Service {
|
|||||||
}
|
}
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
|
|
||||||
|
// ... 获取分辨率逻辑保持不变 ...
|
||||||
// 获取屏幕真实尺寸和刷新率
|
// 获取屏幕真实尺寸和刷新率
|
||||||
WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
|
WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
|
||||||
DisplayMetrics dm = new DisplayMetrics();
|
DisplayMetrics dm = new DisplayMetrics();
|
||||||
@@ -165,6 +175,7 @@ public class ScreenCaptureService extends Service {
|
|||||||
// 分辨率缩放逻辑:如果设备分辨率大于 1080p,则等比例缩小到 1080p 以内
|
// 分辨率缩放逻辑:如果设备分辨率大于 1080p,则等比例缩小到 1080p 以内
|
||||||
int captureWidth = realWidth;
|
int captureWidth = realWidth;
|
||||||
int captureHeight = realHeight;
|
int captureHeight = realHeight;
|
||||||
|
// 模拟器或低配设备建议使用 720p 甚至 480p,否则软件编码 1080p 会导致卡死
|
||||||
int maxResolution = 1920; // 1080p 的长边
|
int maxResolution = 1920; // 1080p 的长边
|
||||||
|
|
||||||
if (Math.max(realWidth, realHeight) > maxResolution) {
|
if (Math.max(realWidth, realHeight) > maxResolution) {
|
||||||
@@ -202,8 +213,21 @@ public class ScreenCaptureService extends Service {
|
|||||||
inputHandler.setResolutionRequestListener(this::onRemoteResolutionRequested);
|
inputHandler.setResolutionRequestListener(this::onRemoteResolutionRequested);
|
||||||
inputHandler.setStreamModeListener(this::onStreamModeRequested);
|
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;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +238,34 @@ public class ScreenCaptureService extends Service {
|
|||||||
* 3. 兜底使用 root shell 方式。
|
* 3. 兜底使用 root shell 方式。
|
||||||
*/
|
*/
|
||||||
private InputExecutor createInputExecutor() {
|
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)) {
|
if (SignatureUtils.isSystemSignature(this) || SignatureUtils.isSharedSystemUid(this)) {
|
||||||
Log.i(TAG, "Input executor: SystemInputUtils (system signature)");
|
Log.i(TAG, "Input executor: SystemInputUtils (system signature)");
|
||||||
return new SystemInputUtils();
|
return new SystemInputUtils();
|
||||||
@@ -488,6 +540,8 @@ public class ScreenCaptureService extends Service {
|
|||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
isShuttingDown = true;
|
isShuttingDown = true;
|
||||||
|
sResultCode = Activity.RESULT_CANCELED;
|
||||||
|
sResultData = null;
|
||||||
if (instance == this) {
|
if (instance == this) {
|
||||||
instance = null;
|
instance = null;
|
||||||
}
|
}
|
||||||
@@ -572,7 +626,11 @@ public class ScreenCaptureService extends Service {
|
|||||||
|
|
||||||
// 初始化屏幕捕获
|
// 初始化屏幕捕获
|
||||||
screenCapturer = new ScreenCapturerAndroid(resultData, new MediaProjection.Callback() {
|
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);
|
webRtcClient.setVideoCapturer(screenCapturer, width, height, fps);
|
||||||
@@ -662,19 +720,19 @@ public class ScreenCaptureService extends Service {
|
|||||||
* PASSWORD: 与被控端固定密码比对。
|
* PASSWORD: 与被控端固定密码比对。
|
||||||
*/
|
*/
|
||||||
private String verifyAuth(String authType, String authValue) {
|
private String verifyAuth(String authType, String authValue) {
|
||||||
if (authValue == null) authValue = "";
|
String valueToVerify = (authValue == null) ? "" : authValue;
|
||||||
if ("CODE".equalsIgnoreCase(authType)) {
|
if ("CODE".equalsIgnoreCase(authType)) {
|
||||||
String expected = AuthSettings.getDynamicCode(this);
|
String expected = AuthSettings.getDynamicCode(this);
|
||||||
if (expected == null || expected.isEmpty()) {
|
if (expected == null || expected.isEmpty()) {
|
||||||
return "被控端未生成动态验证码";
|
return "被控端未生成动态验证码";
|
||||||
}
|
}
|
||||||
return expected.equals(authValue) ? null : "动态验证码错误";
|
return expected.equals(valueToVerify) ? null : "动态验证码错误";
|
||||||
} else if ("PASSWORD".equalsIgnoreCase(authType)) {
|
} else if ("PASSWORD".equalsIgnoreCase(authType)) {
|
||||||
String expected = AuthSettings.getFixedPassword(this);
|
String expected = AuthSettings.getFixedPassword(this);
|
||||||
if (expected == null || expected.isEmpty()) {
|
if (expected == null || expected.isEmpty()) {
|
||||||
return "被控端未设置固定密码";
|
return "被控端未设置固定密码";
|
||||||
}
|
}
|
||||||
return expected.equals(authValue) ? null : "固定密码错误";
|
return expected.equals(valueToVerify) ? null : "固定密码错误";
|
||||||
}
|
}
|
||||||
return "未知的鉴权类型: " + authType;
|
return "未知的鉴权类型: " + authType;
|
||||||
}
|
}
|
||||||
@@ -798,6 +856,24 @@ public class ScreenCaptureService extends Service {
|
|||||||
Log.i(TAG, "Sent CONNECTION_REJECTED to " + controllerId);
|
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) {
|
private void handleIceCandidate(SignalMessage message) {
|
||||||
try {
|
try {
|
||||||
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
|
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
|
||||||
@@ -826,7 +902,7 @@ public class ScreenCaptureService extends Service {
|
|||||||
.setContentText(contentText)
|
.setContentText(contentText)
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_camera)
|
.setSmallIcon(android.R.drawable.ic_menu_camera)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@@ -836,7 +912,7 @@ public class ScreenCaptureService extends Service {
|
|||||||
NotificationChannel channel = new NotificationChannel(
|
NotificationChannel channel = new NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"屏幕共享服务",
|
"屏幕共享服务",
|
||||||
NotificationManager.IMPORTANCE_LOW);
|
NotificationManager.IMPORTANCE_DEFAULT);
|
||||||
channel.setDescription("远程控制屏幕采集前台服务");
|
channel.setDescription("远程控制屏幕采集前台服务");
|
||||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||||
manager.createNotificationChannel(channel);
|
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();
|
.createInitializationOptions();
|
||||||
PeerConnectionFactory.initialize(initOptions);
|
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());
|
DefaultVideoDecoderFactory decoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext());
|
||||||
|
|
||||||
// 打印所有支持的编码格式
|
// 打印所有支持的编码格式
|
||||||
@@ -541,16 +544,31 @@ public class WebRtcClient {
|
|||||||
String[] lines = sdp.split("\n");
|
String[] lines = sdp.split("\n");
|
||||||
StringBuilder newSdp = new StringBuilder();
|
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) {
|
for (String line : lines) {
|
||||||
String trimmedLine = line.trim();
|
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];
|
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()) {
|
if (videoPayloads.isEmpty()) {
|
||||||
return sdp;
|
return sdp;
|
||||||
}
|
}
|
||||||
@@ -563,7 +581,6 @@ public class WebRtcClient {
|
|||||||
String[] parts = trimmedLine.split(" ");
|
String[] parts = trimmedLine.split(" ");
|
||||||
if (parts.length > 3) {
|
if (parts.length > 3) {
|
||||||
StringBuilder mLine = new StringBuilder(parts[0] + " " + parts[1] + " " + parts[2]);
|
StringBuilder mLine = new StringBuilder(parts[0] + " " + parts[1] + " " + parts[2]);
|
||||||
// 将 H264 放在最前面(如果存在)
|
|
||||||
for (String payload : videoPayloads) {
|
for (String payload : videoPayloads) {
|
||||||
mLine.append(" ").append(payload);
|
mLine.append(" ").append(payload);
|
||||||
}
|
}
|
||||||
@@ -577,14 +594,7 @@ public class WebRtcClient {
|
|||||||
newSdp.append(trimmedLine).append("\r\n");
|
newSdp.append(trimmedLine).append("\r\n");
|
||||||
}
|
}
|
||||||
} else if (trimmedLine.startsWith("a=fmtp:")) {
|
} else if (trimmedLine.startsWith("a=fmtp:")) {
|
||||||
boolean isVideoPayload = false;
|
if (videoPayloads.contains(trimmedLine.split(":")[1].split(" ")[0])) {
|
||||||
for (String payload : videoPayloads) {
|
|
||||||
if (trimmedLine.startsWith("a=fmtp:" + payload)) {
|
|
||||||
isVideoPayload = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isVideoPayload) {
|
|
||||||
String bonus = ";x-google-start-bitrate=" + START_BITRATE_KBPS
|
String bonus = ";x-google-start-bitrate=" + START_BITRATE_KBPS
|
||||||
+ ";x-google-max-bitrate=" + MAX_BITRATE_KBPS
|
+ ";x-google-max-bitrate=" + MAX_BITRATE_KBPS
|
||||||
+ ";x-google-min-bitrate=" + MIN_BITRATE_KBPS;
|
+ ";x-google-min-bitrate=" + MIN_BITRATE_KBPS;
|
||||||
|
|||||||
@@ -61,117 +61,14 @@
|
|||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<!-- 安全验证:动态验证码 + 固定密码 -->
|
<!-- 安全与输入设置入口 -->
|
||||||
<TextView
|
<Button
|
||||||
android:layout_width="wrap_content"
|
android:id="@+id/btn_open_settings"
|
||||||
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="12dp"
|
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:layout_marginBottom="16dp"
|
||||||
android:textSize="13sp"
|
android:text="@string/btn_open_settings" />
|
||||||
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>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btn_start"
|
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_desc">关闭后,控制端必须使用动态验证码或固定密码才能连接</string>
|
||||||
<string name="allow_no_auth_on">已允许免密连接</string>
|
<string name="allow_no_auth_on">已允许免密连接</string>
|
||||||
<string name="allow_no_auth_off">已关闭免密连接</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>
|
</resources>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ watch(
|
|||||||
(stream) => {
|
(stream) => {
|
||||||
if (videoRef.value && stream) {
|
if (videoRef.value && stream) {
|
||||||
videoRef.value.srcObject = stream;
|
videoRef.value.srcObject = stream;
|
||||||
|
// 部分浏览器在 srcObject 设置后不会自动播放(尤其是无用户手势时),需显式 play() 兜底
|
||||||
|
videoRef.value.play().catch(() => {});
|
||||||
hasStream.value = true;
|
hasStream.value = true;
|
||||||
} else {
|
} else {
|
||||||
hasStream.value = false;
|
hasStream.value = false;
|
||||||
|
|||||||
@@ -75,7 +75,18 @@ export class WebRtcController {
|
|||||||
this.onIceState && this.onIceState(s);
|
this.onIceState && this.onIceState(s);
|
||||||
};
|
};
|
||||||
this.pc.ontrack = (e) => {
|
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);
|
this.pc.ondatachannel = (e) => this.setupDataChannel(e.channel);
|
||||||
|
|
||||||
@@ -87,12 +98,59 @@ export class WebRtcController {
|
|||||||
this.setupDataChannel(dc);
|
this.setupDataChannel(dc);
|
||||||
|
|
||||||
const offer = await this.pc.createOffer();
|
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);
|
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();
|
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) {
|
setupDataChannel(dc) {
|
||||||
this.dataChannel = dc;
|
this.dataChannel = dc;
|
||||||
dc.onopen = () => this.onDataChannelState && this.onDataChannelState(true);
|
dc.onopen = () => this.onDataChannelState && this.onDataChannelState(true);
|
||||||
@@ -119,6 +177,11 @@ export class WebRtcController {
|
|||||||
if (!this.pc) return;
|
if (!this.pc) return;
|
||||||
try {
|
try {
|
||||||
await this.pc.setRemoteDescription({ type: 'answer', sdp });
|
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) {
|
} catch (e) {
|
||||||
this.onError && this.onError('设置远端描述(ANSWER)失败: ' + (e?.message || e));
|
this.onError && this.onError('设置远端描述(ANSWER)失败: ' + (e?.message || e));
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
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.daemon.jvm.options=-Xmx4G
|
||||||
kotlin.compiler.execution.strategy=daemon
|
kotlin.compiler.execution.strategy=daemon
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
|||||||
@@ -128,15 +128,82 @@ class WebRtcController {
|
|||||||
'optional': [],
|
'optional': [],
|
||||||
};
|
};
|
||||||
final offer = await _pc!.createOffer(constraints);
|
final offer = await _pc!.createOffer(constraints);
|
||||||
await _pc!.setLocalDescription(offer);
|
// 与 WebRTCControlled 的 optimizeSdp 保持一致:Offer 的 m=video 行优先 VP8/VP9,
|
||||||
_sendOffer(offer.sdp!, authType: authType, authValue: authValue);
|
// 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) {
|
void _onTrack(RTCTrackEvent event) {
|
||||||
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
|
if (event.track.kind != 'video') return;
|
||||||
renderer.srcObject = event.streams[0];
|
_bindRemoteVideo(event);
|
||||||
onRemoteStream?.call(renderer);
|
}
|
||||||
|
|
||||||
|
/// 绑定远端视频轨道到渲染器。
|
||||||
|
///
|
||||||
|
/// 与 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) {
|
void _onDataChannel(RTCDataChannel channel) {
|
||||||
|
|||||||
Reference in New Issue
Block a user