diff --git a/WebRTCControlled/app/src/main/AndroidManifest.xml b/WebRTCControlled/app/src/main/AndroidManifest.xml index 66bb3a1..0e99fd3 100644 --- a/WebRTCControlled/app/src/main/AndroidManifest.xml +++ b/WebRTCControlled/app/src/main/AndroidManifest.xml @@ -25,6 +25,11 @@ + + 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 { + + /** 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 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(); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/settings/SettingsViewModel.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/settings/SettingsViewModel.java new file mode 100644 index 0000000..3129bbe --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/settings/SettingsViewModel.java @@ -0,0 +1,6 @@ +package com.ttstd.controlled.activity.settings; + +import com.ttstd.controlled.base.BaseViewModel; + +public class SettingsViewModel extends BaseViewModel { +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java index 091d060..fea2d90 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java @@ -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); diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/InputSettings.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/InputSettings.java new file mode 100644 index 0000000..3558786 --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/InputSettings.java @@ -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(); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java index d4c7b04..fc1b74c 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java @@ -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 videoPayloads = new ArrayList<>(); - // 识别所有视频负载类型 + // 按编解码器类型分组收集 payload type。 + // 重排时优先 VP8/VP9(控制端/浏览器原生解码兼容性最好,桌面端与 Chromium/Linux 均可稳定硬/软解), + // H264 放最后作为兜底,避免部分环境无法解码 H264 High Profile 而黑屏。 + List vp8 = new ArrayList<>(); + List vp9 = new ArrayList<>(); + List 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 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; diff --git a/WebRTCControlled/app/src/main/res/layout/activity_main.xml b/WebRTCControlled/app/src/main/res/layout/activity_main.xml index 833cdd7..cc83c34 100644 --- a/WebRTCControlled/app/src/main/res/layout/activity_main.xml +++ b/WebRTCControlled/app/src/main/res/layout/activity_main.xml @@ -61,117 +61,14 @@ android:textSize="16sp" android:textStyle="bold" /> - - +