feat: add root shell
This commit is contained in:
@@ -98,7 +98,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
boolean systemSigned = SignatureUtils.isSystemSignature(this)
|
||||
|| SignatureUtils.isSharedSystemUid(this)
|
||||
|| SignatureUtils.isSystemUid();
|
||||
boolean rooted = SignatureUtils.isDeviceRooted() && SignatureUtils.isAppHasRootPermission();
|
||||
boolean rooted = SignatureUtils.isDeviceRooted() || SignatureUtils.isAppHasRootPermission();
|
||||
isPrivileged = systemSigned || rooted;
|
||||
Log.e(TAG, "initData: isPrivileged = " + isPrivileged);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,15 @@ public class InputCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放持有的资源。
|
||||
*/
|
||||
public void release() {
|
||||
if (inputExecutor != null) {
|
||||
inputExecutor.release();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMotionEvent(ControlMessage command) {
|
||||
int action = command.getMotionAction();
|
||||
float relX = (float) command.getX();
|
||||
|
||||
@@ -9,4 +9,9 @@ public interface InputExecutor {
|
||||
void injectSwipe(int x1, int y1, int x2, int y2, long duration);
|
||||
void injectKeyEvent(int keyCode);
|
||||
void injectMotionEvent(int action, int x, int y);
|
||||
|
||||
/**
|
||||
* 释放执行器持有的资源(如持久的 Shell 进程)。
|
||||
*/
|
||||
default void release() {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.ttstd.controlled.input;
|
||||
|
||||
import android.util.Log;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 通过 Root Shell (su) 执行注入操作的实现类。
|
||||
* 解决普通 Shell 无法获取足够权限执行 input 命令的问题。
|
||||
*/
|
||||
public class RootShellInputUtils implements InputExecutor {
|
||||
private static final String TAG = "RootShellInputUtils";
|
||||
|
||||
private Process mSuProcess;
|
||||
private DataOutputStream mSuStream;
|
||||
|
||||
@Override
|
||||
public void injectTap(int x, int y) {
|
||||
executeRootCommand(String.format(Locale.US, "input tap %d %d", x, y));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void injectLongPress(int x, int y) {
|
||||
injectSwipe(x, y, x, y, 1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void injectSwipe(int x1, int y1, int x2, int y2, long duration) {
|
||||
executeRootCommand(String.format(Locale.US, "input swipe %d %d %d %d %d", x1, y1, x2, y2, duration));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void injectKeyEvent(int keyCode) {
|
||||
executeRootCommand(String.format(Locale.US, "input keyevent %d", keyCode));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void injectMotionEvent(int action, int x, int y) {
|
||||
// Root Shell 下执行 input 命令开销较大
|
||||
if (action == 0) { // ACTION_DOWN
|
||||
injectTap(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行需要 root 权限的命令。
|
||||
* 使用持久的 su 进程以减少重复创建进程的开销。
|
||||
*/
|
||||
private synchronized void executeRootCommand(String command) {
|
||||
try {
|
||||
if (mSuProcess == null || mSuStream == null) {
|
||||
mSuProcess = Runtime.getRuntime().exec("su");
|
||||
mSuStream = new DataOutputStream(mSuProcess.getOutputStream());
|
||||
}
|
||||
Log.d(TAG, "Executing root command: " + command);
|
||||
mSuStream.writeBytes(command + "\n");
|
||||
mSuStream.flush();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error executing root command: " + command, e);
|
||||
closeSuProcess();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release() {
|
||||
closeSuProcess();
|
||||
}
|
||||
|
||||
private synchronized void closeSuProcess() {
|
||||
try {
|
||||
if (mSuStream != null) {
|
||||
mSuStream.writeBytes("exit\n");
|
||||
mSuStream.flush();
|
||||
mSuStream.close();
|
||||
}
|
||||
if (mSuProcess != null) {
|
||||
mSuProcess.destroy();
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
} finally {
|
||||
mSuStream = null;
|
||||
mSuProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void finalize() throws Throwable {
|
||||
closeSuProcess();
|
||||
super.finalize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ public class ShellInputUtils implements InputExecutor {
|
||||
private void executeShellCommand(String command) {
|
||||
try {
|
||||
Log.d(TAG, "Executing shell command: " + command);
|
||||
Runtime.getRuntime().exec(command);
|
||||
Process process = Runtime.getRuntime().exec(command);
|
||||
int exitCode = process.waitFor();
|
||||
Log.i(TAG, "Shell command executed with exit code " + exitCode);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error executing shell command: " + command, e);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.ttstd.controlled.accessibility.KeyboardAccessibilityService;
|
||||
import com.ttstd.controlled.input.AccessibilityInputUtils;
|
||||
import com.ttstd.controlled.input.InputCommandHandler;
|
||||
import com.ttstd.controlled.input.InputExecutor;
|
||||
import com.ttstd.controlled.input.RootShellInputUtils;
|
||||
import com.ttstd.controlled.input.ShellInputUtils;
|
||||
import com.ttstd.controlled.input.SystemInputUtils;
|
||||
import com.ttstd.controlled.utils.SignatureUtils;
|
||||
@@ -57,7 +58,6 @@ public class ScreenCaptureService extends Service {
|
||||
public static final String EXTRA_SERVER_URL = "server_url";
|
||||
public static final String EXTRA_DEVICE_ID = "device_id";
|
||||
|
||||
private MediaProjection mediaProjection;
|
||||
private ScreenCapturerAndroid screenCapturer;
|
||||
private WebSocketClient wsClient;
|
||||
private WebRtcClient webRtcClient;
|
||||
@@ -65,6 +65,9 @@ public class ScreenCaptureService extends Service {
|
||||
private EglBase eglBase;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/** 标记服务是否已经初始化,避免 onStartCommand 多次调用导致重复创建 */
|
||||
private boolean isInitialized = false;
|
||||
|
||||
/** 静态实例引用,供弹窗 Activity 在无需绑定的情况下回调本服务。 */
|
||||
private static ScreenCaptureService instance;
|
||||
|
||||
@@ -118,6 +121,12 @@ public class ScreenCaptureService extends Service {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
if (isInitialized) {
|
||||
Log.i(TAG, "Service already initialized, ignoring parameters");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
isInitialized = true;
|
||||
|
||||
// 获取屏幕真实尺寸和刷新率
|
||||
WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
|
||||
DisplayMetrics dm = new DisplayMetrics();
|
||||
@@ -177,6 +186,10 @@ public class ScreenCaptureService extends Service {
|
||||
Log.i(TAG, "Input executor: SystemInputUtils (system signature)");
|
||||
return new SystemInputUtils();
|
||||
}
|
||||
if (SignatureUtils.isDeviceRooted() || SignatureUtils.isAppHasRootPermission()) {
|
||||
Log.i(TAG, "Input executor: RootShellInputUtils (rooted)");
|
||||
return new RootShellInputUtils();
|
||||
}
|
||||
if (KeyboardAccessibilityService.getInstance() != null) {
|
||||
Log.i(TAG, "Input executor: AccessibilityInputUtils (accessibility enabled)");
|
||||
return new AccessibilityInputUtils();
|
||||
@@ -238,18 +251,15 @@ public class ScreenCaptureService extends Service {
|
||||
}
|
||||
if (eglBase != null) {
|
||||
eglBase.release();
|
||||
eglBase = null;
|
||||
}
|
||||
if (mediaProjection != null) {
|
||||
mediaProjection.stop();
|
||||
if (inputHandler != null) {
|
||||
inputHandler.release();
|
||||
inputHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void startScreenCapture(int resultCode, Intent resultData, String serverUrl, String deviceId, int width, int height, int fps) {
|
||||
// 初始化 MediaProjection
|
||||
MediaProjectionManager projectionManager =
|
||||
(MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
|
||||
mediaProjection = projectionManager.getMediaProjection(resultCode, resultData);
|
||||
|
||||
// 初始化 EGL
|
||||
eglBase = EglBase.create();
|
||||
|
||||
@@ -355,10 +365,21 @@ public class ScreenCaptureService extends Service {
|
||||
String controllerId = pendingControllerId;
|
||||
controllingName = pendingControllerName;
|
||||
clearPendingRequest();
|
||||
|
||||
// 1. 通过更新通知文字触发屏幕刷新(扰动方案)
|
||||
// 状态栏的变化会强制 MediaProjection 产生至少一帧,解决静态画面黑屏问题。
|
||||
updateNotification("正在接受 " + controllingName + " 的控制...");
|
||||
|
||||
// 2. 建立连接
|
||||
webRtcClient.createPeerConnectionAndAnswer(offerSdp, controllerId);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateNotification(String contentText) {
|
||||
NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
manager.notify(NOTIFICATION_ID, createNotification(contentText));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户在弹窗中点击“拒绝”:向主控端回送拒绝通知,不建立连接。
|
||||
*/
|
||||
@@ -401,6 +422,10 @@ public class ScreenCaptureService extends Service {
|
||||
? controllingName : controllerId;
|
||||
Log.i(TAG, "Remote controller disconnected: " + name);
|
||||
showToast(getString(R.string.controller_disconnected_message, name));
|
||||
|
||||
// 断开后恢复原始通知状态
|
||||
updateNotification("远程控制服务正在运行");
|
||||
|
||||
if (stateListener != null) {
|
||||
stateListener.onControllerDisconnected(name);
|
||||
}
|
||||
@@ -446,15 +471,21 @@ public class ScreenCaptureService extends Service {
|
||||
}
|
||||
|
||||
private Notification createNotification() {
|
||||
return createNotification("远程控制服务正在运行");
|
||||
}
|
||||
|
||||
private Notification createNotification(String contentText) {
|
||||
Intent notificationIntent = new Intent(this, MainActivity.class);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("屏幕共享中")
|
||||
.setContentText("远程控制服务正在运行")
|
||||
.setContentText(contentText)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_camera)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,12 +39,10 @@ public class WebRtcClient {
|
||||
private static final String STREAM_ID = "screen_stream";
|
||||
private static final String DATA_CHANNEL_LABEL = "control_channel";
|
||||
|
||||
// 码率优化参数(单位 kbps)。屏幕内容(文字/边缘多)对码率敏感,
|
||||
// 过低的下限会产生色块。提高下限可显著改善画质;
|
||||
// 若被控端上行带宽较弱(如蜂窝网络),请适当下调 MIN_BITRATE_KBPS。
|
||||
private static final int MIN_BITRATE_KBPS = 5000; // 带宽估计下限,不低于此值
|
||||
private static final int START_BITRATE_KBPS = 12000; // 连接初始码率猜测
|
||||
private static final int MAX_BITRATE_KBPS = 40000; // 带宽估计上限
|
||||
// 码率优化参数(单位 kbps)。
|
||||
private static final int MIN_BITRATE_KBPS = 1000; // 1 Mbps
|
||||
private static final int START_BITRATE_KBPS = 2000; // 2 Mbps
|
||||
private static final int MAX_BITRATE_KBPS = 8000; // 8 Mbps
|
||||
|
||||
private final Context context;
|
||||
private final WebSocketClient wsClient;
|
||||
@@ -54,7 +52,9 @@ public class WebRtcClient {
|
||||
private PeerConnectionFactory peerConnectionFactory;
|
||||
private PeerConnection peerConnection;
|
||||
private DataChannel dataChannel;
|
||||
private VideoSource videoSource;
|
||||
private VideoTrack videoTrack;
|
||||
private org.webrtc.RtpSender videoSender;
|
||||
private EglBase eglBase;
|
||||
private String currentControllerId;
|
||||
|
||||
@@ -132,7 +132,7 @@ public class WebRtcClient {
|
||||
|
||||
SurfaceTextureHelper surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglBase.getEglBaseContext());
|
||||
// 对于屏幕共享,isScreencast 设为 true,这会优化细节保留
|
||||
VideoSource videoSource = peerConnectionFactory.createVideoSource(true);
|
||||
videoSource = peerConnectionFactory.createVideoSource(true);
|
||||
capturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver());
|
||||
capturer.startCapture(width, height, fps);
|
||||
|
||||
@@ -191,7 +191,8 @@ public class WebRtcClient {
|
||||
public void onIceConnectionChange(PeerConnection.IceConnectionState newState) {
|
||||
Log.d(TAG, "ICE connection state: " + newState);
|
||||
if (newState == PeerConnection.IceConnectionState.CONNECTED) {
|
||||
refreshVideoSource();
|
||||
// 连接成功时,诱发编码器产生一个关键帧,而不是重启采集器
|
||||
triggerKeyFrame();
|
||||
} else if (newState == PeerConnection.IceConnectionState.DISCONNECTED
|
||||
|| newState == PeerConnection.IceConnectionState.FAILED
|
||||
|| newState == PeerConnection.IceConnectionState.CLOSED) {
|
||||
@@ -218,7 +219,7 @@ public class WebRtcClient {
|
||||
peerConnection = peerConnectionFactory.createPeerConnection(config, peerObserver);
|
||||
|
||||
if (videoTrack != null) {
|
||||
peerConnection.addTrack(videoTrack, Collections.singletonList(STREAM_ID));
|
||||
videoSender = peerConnection.addTrack(videoTrack, Collections.singletonList(STREAM_ID));
|
||||
}
|
||||
|
||||
// 注意:DataChannel 由控制端(Offer 方)创建并协商,
|
||||
@@ -363,21 +364,20 @@ public class WebRtcClient {
|
||||
}
|
||||
|
||||
private String optimizeSdp(String sdp) {
|
||||
// 使用 \n 分割以提高兼容性,并处理可能的 \r
|
||||
String[] lines = sdp.split("\n");
|
||||
StringBuilder newSdp = new StringBuilder();
|
||||
|
||||
List<String> h264Payloads = new ArrayList<>();
|
||||
// 首先找到所有的 H264 payload type (可能包含多个 profile)
|
||||
List<String> videoPayloads = new ArrayList<>();
|
||||
// 识别所有视频负载类型
|
||||
for (String line : lines) {
|
||||
String trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith("a=rtpmap:") && trimmedLine.contains("H264/90000")) {
|
||||
if (trimmedLine.startsWith("a=rtpmap:") && (trimmedLine.contains("H264/90000") || trimmedLine.contains("VP8/90000") || trimmedLine.contains("VP9/90000"))) {
|
||||
String payload = trimmedLine.split(":")[1].split(" ")[0];
|
||||
h264Payloads.add(payload);
|
||||
videoPayloads.add(payload);
|
||||
}
|
||||
}
|
||||
|
||||
if (h264Payloads.isEmpty()) {
|
||||
if (videoPayloads.isEmpty()) {
|
||||
return sdp;
|
||||
}
|
||||
|
||||
@@ -386,17 +386,15 @@ public class WebRtcClient {
|
||||
if (trimmedLine.isEmpty()) continue;
|
||||
|
||||
if (trimmedLine.startsWith("m=video")) {
|
||||
// 将所有 H264 payload 移到最前面以优先使用
|
||||
String[] parts = trimmedLine.split(" ");
|
||||
if (parts.length > 3) {
|
||||
StringBuilder mLine = new StringBuilder(parts[0] + " " + parts[1] + " " + parts[2]);
|
||||
// 先添加所有的 H264 payload
|
||||
for (String h264 : h264Payloads) {
|
||||
mLine.append(" ").append(h264);
|
||||
// 将 H264 放在最前面(如果存在)
|
||||
for (String payload : videoPayloads) {
|
||||
mLine.append(" ").append(payload);
|
||||
}
|
||||
// 再添加其余的 payload
|
||||
for (int i = 3; i < parts.length; i++) {
|
||||
if (!h264Payloads.contains(parts[i])) {
|
||||
if (!videoPayloads.contains(parts[i])) {
|
||||
mLine.append(" ").append(parts[i]);
|
||||
}
|
||||
}
|
||||
@@ -405,25 +403,18 @@ public class WebRtcClient {
|
||||
newSdp.append(trimmedLine).append("\r\n");
|
||||
}
|
||||
} else if (trimmedLine.startsWith("a=fmtp:")) {
|
||||
boolean isH264 = false;
|
||||
for (String h264 : h264Payloads) {
|
||||
if (trimmedLine.startsWith("a=fmtp:" + h264)) {
|
||||
isH264 = true;
|
||||
boolean isVideoPayload = false;
|
||||
for (String payload : videoPayloads) {
|
||||
if (trimmedLine.startsWith("a=fmtp:" + payload)) {
|
||||
isVideoPayload = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isH264) {
|
||||
// 为 H264 注入码率优化参数。
|
||||
// 屏幕内容(文字/边缘)对码率敏感,过低的下限会产生色块;
|
||||
// 提高下限可显著改善画质。若被控端上行带宽较弱,请适当下调 MIN。
|
||||
if (isVideoPayload) {
|
||||
String bonus = ";x-google-start-bitrate=" + START_BITRATE_KBPS
|
||||
+ ";x-google-max-bitrate=" + MAX_BITRATE_KBPS
|
||||
+ ";x-google-min-bitrate=" + MIN_BITRATE_KBPS;
|
||||
if (!trimmedLine.contains("x-google-start-bitrate")) {
|
||||
newSdp.append(trimmedLine).append(bonus).append("\r\n");
|
||||
} else {
|
||||
newSdp.append(trimmedLine).append("\r\n");
|
||||
}
|
||||
newSdp.append(trimmedLine).append(bonus).append("\r\n");
|
||||
} else {
|
||||
newSdp.append(trimmedLine).append("\r\n");
|
||||
}
|
||||
@@ -464,14 +455,17 @@ public class WebRtcClient {
|
||||
wsClient.sendMessage(msg);
|
||||
}
|
||||
|
||||
private void refreshVideoSource() {
|
||||
if (videoCapturer != null) {
|
||||
try {
|
||||
Log.d(TAG, "Refreshing video capture to force a frame update for new connection");
|
||||
videoCapturer.stopCapture();
|
||||
videoCapturer.startCapture(videoWidth, videoHeight, videoFps);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error refreshing video capture: " + e.getMessage());
|
||||
/**
|
||||
* 通过重新应用 RtpParameters 诱发编码器生成关键帧(I-Frame)。
|
||||
* 相比重启采集器,这种方法更平滑,不会导致 MediaProjection 管道断开。
|
||||
*/
|
||||
public void triggerKeyFrame() {
|
||||
if (videoSender != null) {
|
||||
Log.d(TAG, "Triggering key frame via RtpSender parameters update");
|
||||
org.webrtc.RtpParameters parameters = videoSender.getParameters();
|
||||
if (parameters != null && !parameters.encodings.isEmpty()) {
|
||||
// 即使不修改任何参数,重新设置一次 setParameters 也会诱发许多编码器产生 I-Frame
|
||||
videoSender.setParameters(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user