feat: add root shell

This commit is contained in:
TongTongStudio
2026-07-17 00:34:49 +08:00
parent 45ee9ef60c
commit 474c7942a3
7 changed files with 187 additions and 53 deletions

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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() {}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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,21 +403,17 @@ 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");
@@ -427,9 +421,6 @@ public class WebRtcClient {
} else {
newSdp.append(trimmedLine).append("\r\n");
}
} else {
newSdp.append(trimmedLine).append("\r\n");
}
}
return newSdp.toString();
}
@@ -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);
}
}
}