This commit is contained in:
2026-07-13 10:33:17 +08:00
commit 87f98799f7
49 changed files with 3011 additions and 0 deletions

2
WebRTCControlled/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build/
/.idea/

View File

@@ -0,0 +1,51 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.tt.controlled'
compileSdk 34
defaultConfig {
applicationId "com.tt.controlled"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildFeatures {
dataBinding true
buildConfig true
aidl true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
// WebRTC
implementation 'io.github.nicholasgasior:webrtc-android:1.0.0'
// OkHttp for WebSocket
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
// Gson for JSON
implementation 'com.google.code.gson:gson:2.10.1'
}

View File

@@ -0,0 +1 @@
# Add project specific ProGuard rules here.

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INJECT_EVENTS" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MaterialComponents.DayNight">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.ScreenCaptureService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
</application>
</manifest>

View File

@@ -0,0 +1,135 @@
package com.tt.controlled;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.tt.controlled.service.ScreenCaptureService;
import java.util.UUID;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "ControlledMain";
private static final int REQUEST_MEDIA_PROJECTION = 1001;
private static final int REQUEST_NOTIFICATION_PERMISSION = 1002;
private EditText etServerUrl;
private EditText etDeviceId;
private Button btnStart;
private Button btnStop;
private TextView tvStatus;
private boolean isServiceRunning = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
etServerUrl = findViewById(R.id.et_server_url);
etDeviceId = findViewById(R.id.et_device_id);
btnStart = findViewById(R.id.btn_start);
btnStop = findViewById(R.id.btn_stop);
tvStatus = findViewById(R.id.tv_status);
// 默认服务器地址
etServerUrl.setText("ws://192.168.1.100:8080/ws/signal");
// 生成设备ID
etDeviceId.setText("controlled_" + UUID.randomUUID().toString().substring(0, 8));
btnStart.setOnClickListener(v -> startScreenSharing());
btnStop.setOnClickListener(v -> stopScreenSharing());
updateUI(false);
}
private void startScreenSharing() {
// 检查通知权限 (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.POST_NOTIFICATIONS},
REQUEST_NOTIFICATION_PERMISSION);
return;
}
}
// 请求屏幕录制权限
MediaProjectionManager projectionManager =
(MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_MEDIA_PROJECTION) {
if (resultCode == RESULT_OK && data != null) {
Intent serviceIntent = new Intent(this, ScreenCaptureService.class);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_DATA, data);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_SERVER_URL, etServerUrl.getText().toString());
serviceIntent.putExtra(ScreenCaptureService.EXTRA_DEVICE_ID, etDeviceId.getText().toString());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
updateUI(true);
Toast.makeText(this, "屏幕共享已启动", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(this, "屏幕录制权限被拒绝", Toast.LENGTH_SHORT).show();
}
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_NOTIFICATION_PERMISSION) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startScreenSharing();
} else {
Toast.makeText(this, "需要通知权限才能运行", Toast.LENGTH_SHORT).show();
}
}
}
private void stopScreenSharing() {
Intent serviceIntent = new Intent(this, ScreenCaptureService.class);
stopService(serviceIntent);
updateUI(false);
Toast.makeText(this, "屏幕共享已停止", Toast.LENGTH_SHORT).show();
}
private void updateUI(boolean running) {
isServiceRunning = running;
btnStart.setEnabled(!running);
btnStop.setEnabled(running);
etServerUrl.setEnabled(!running);
etDeviceId.setEnabled(!running);
tvStatus.setText(running ? "状态: 运行中" : "状态: 已停止");
}
}

View File

@@ -0,0 +1,106 @@
package com.tt.controlled.input;
import android.os.SystemClock;
import android.util.Log;
import android.view.InputDevice;
import android.view.InputEvent;
import android.view.MotionEvent;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.lang.reflect.Method;
/**
* 处理控制端发来的输入指令(触摸、按键)。
* 注意:注入输入事件需要 INJECT_EVENTS 权限,
* 通常需要系统签名或ADB授权才能使用。
*/
public class InputCommandHandler {
private static final String TAG = "InputCommandHandler";
private final Gson gson = new Gson();
private final int screenWidth;
private final int screenHeight;
public InputCommandHandler(int screenWidth, int screenHeight) {
this.screenWidth = screenWidth;
this.screenHeight = screenHeight;
}
public void handleCommand(String commandJson) {
try {
JsonObject command = gson.fromJson(commandJson, JsonObject.class);
String action = command.get("action").getAsString();
switch (action) {
case "TOUCH":
handleTouch(command);
break;
case "KEY":
handleKey(command);
break;
case "SWIPE":
handleSwipe(command);
break;
default:
Log.w(TAG, "Unknown action: " + action);
}
} catch (Exception e) {
Log.e(TAG, "Error handling command: " + e.getMessage(), e);
}
}
private void handleTouch(JsonObject command) {
float relX = command.get("x").getAsFloat();
float relY = command.get("y").getAsFloat();
int x = (int) (relX * screenWidth);
int y = (int) (relY * screenHeight);
Log.d(TAG, "Touch at: " + x + ", " + y);
injectTap(x, y);
}
private void handleKey(JsonObject command) {
int keyCode = command.get("keyCode").getAsInt();
Log.d(TAG, "Key press: " + keyCode);
// 使用 Runtime.exec 模拟按键需要root或ADB权限
try {
Runtime.getRuntime().exec("input keyevent " + keyCode);
} catch (Exception e) {
Log.e(TAG, "Error injecting key event", e);
}
}
private void handleSwipe(JsonObject command) {
float relX1 = command.get("x1").getAsFloat();
float relY1 = command.get("y1").getAsFloat();
float relX2 = command.get("x2").getAsFloat();
float relY2 = command.get("y2").getAsFloat();
long duration = command.has("duration") ? command.get("duration").getAsLong() : 300;
int x1 = (int) (relX1 * screenWidth);
int y1 = (int) (relY1 * screenHeight);
int x2 = (int) (relX2 * screenWidth);
int y2 = (int) (relY2 * screenHeight);
Log.d(TAG, "Swipe from (" + x1 + "," + y1 + ") to (" + x2 + "," + y2 + ")");
injectSwipe(x1, y1, x2, y2, duration);
}
private void injectTap(int x, int y) {
try {
Runtime.getRuntime().exec("input tap " + x + " " + y);
} catch (Exception e) {
Log.e(TAG, "Error injecting tap event", e);
}
}
private void injectSwipe(int x1, int y1, int x2, int y2, long duration) {
try {
Runtime.getRuntime().exec("input swipe " + x1 + " " + y1 + " " + x2 + " " + y2 + " " + duration);
} catch (Exception e) {
Log.e(TAG, "Error injecting swipe event", e);
}
}
}

View File

@@ -0,0 +1,246 @@
package com.tt.controlled.service;
import android.app.Activity;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.IBinder;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.WindowManager;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import com.tt.controlled.MainActivity;
import com.tt.controlled.input.InputCommandHandler;
import com.tt.controlled.signaling.SignalMessage;
import com.tt.controlled.signaling.WebSocketClient;
import com.tt.controlled.webrtc.WebRtcClient;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import org.webrtc.EglBase;
import org.webrtc.IceCandidate;
import org.webrtc.ScreenCapturerAndroid;
import org.webrtc.SessionDescription;
public class ScreenCaptureService extends Service {
private static final String TAG = "ScreenCaptureService";
private static final String CHANNEL_ID = "screen_capture_channel";
private static final int NOTIFICATION_ID = 1;
public static final String EXTRA_RESULT_CODE = "result_code";
public static final String EXTRA_RESULT_DATA = "result_data";
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;
private InputCommandHandler inputHandler;
private EglBase eglBase;
private final Gson gson = new Gson();
@Override
public void onCreate() {
super.onCreate();
createNotificationChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null) {
stopSelf();
return START_NOT_STICKY;
}
// 创建前台通知
Notification notification = createNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
} else {
startForeground(NOTIFICATION_ID, notification);
}
int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED);
Intent resultData = intent.getParcelableExtra(EXTRA_RESULT_DATA);
String serverUrl = intent.getStringExtra(EXTRA_SERVER_URL);
String deviceId = intent.getStringExtra(EXTRA_DEVICE_ID);
if (resultCode != Activity.RESULT_OK || resultData == null || serverUrl == null || deviceId == null) {
Log.e(TAG, "Invalid service parameters");
stopSelf();
return START_NOT_STICKY;
}
// 获取屏幕尺寸
WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(dm);
inputHandler = new InputCommandHandler(dm.widthPixels, dm.heightPixels);
startScreenCapture(resultCode, resultData, serverUrl, deviceId);
return START_NOT_STICKY;
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onDestroy() {
super.onDestroy();
if (screenCapturer != null) {
screenCapturer.stopCapture();
screenCapturer.dispose();
}
if (webRtcClient != null) {
webRtcClient.close();
}
if (wsClient != null) {
wsClient.disconnect();
}
if (eglBase != null) {
eglBase.release();
}
if (mediaProjection != null) {
mediaProjection.stop();
}
}
private void startScreenCapture(int resultCode, Intent resultData, String serverUrl, String deviceId) {
// 初始化 MediaProjection
MediaProjectionManager projectionManager =
(MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
mediaProjection = projectionManager.getMediaProjection(resultCode, resultData);
// 初始化 EGL
eglBase = EglBase.create();
// 初始化 WebSocket
wsClient = new WebSocketClient(serverUrl, deviceId);
wsClient.setListener(new WebSocketClient.SignalListener() {
@Override
public void onConnected() {
Log.i(TAG, "Connected to signal server");
}
@Override
public void onDisconnected() {
Log.i(TAG, "Disconnected from signal server");
}
@Override
public void onError(String error) {
Log.e(TAG, "WebSocket error: " + error);
}
@Override
public void onMessage(SignalMessage message) {
handleSignalMessage(message);
}
});
wsClient.connect();
// 初始化 WebRTC
webRtcClient = new WebRtcClient(this, wsClient, deviceId);
webRtcClient.initialize(eglBase);
webRtcClient.setInputCallback(commandJson -> inputHandler.handleCommand(commandJson));
// 初始化屏幕捕获
screenCapturer = new ScreenCapturerAndroid(resultData, new ScreenCapturerAndroid.Callback() {
@Override
public void onCapturerStarted(boolean success) {
Log.d(TAG, "Screen capture started: " + success);
}
@Override
public void onCapturerStopped() {
Log.d(TAG, "Screen capture stopped");
}
});
webRtcClient.setVideoCapturer(screenCapturer);
}
private void handleSignalMessage(SignalMessage message) {
String type = message.getType();
if (type == null) return;
switch (type.toUpperCase()) {
case "OFFER":
handleOffer(message);
break;
case "ICE_CANDIDATE":
handleIceCandidate(message);
break;
}
}
private void handleOffer(SignalMessage message) {
String offerSdp = null;
try {
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
offerSdp = payload.get("sdp").getAsString();
} catch (Exception e) {
Log.e(TAG, "Error parsing offer SDP", e);
return;
}
String controllerId = message.getFromDeviceId();
webRtcClient.createPeerConnectionAndAnswer(offerSdp, controllerId);
}
private void handleIceCandidate(SignalMessage message) {
try {
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
String sdpMid = payload.get("sdpMid").getAsString();
int sdpMLineIndex = payload.get("sdpMLineIndex").getAsInt();
String candidate = payload.get("candidate").getAsString();
IceCandidate iceCandidate = new IceCandidate(sdpMid, sdpMLineIndex, candidate);
webRtcClient.addIceCandidate(iceCandidate);
} catch (Exception e) {
Log.e(TAG, "Error parsing ICE candidate", e);
}
}
private Notification createNotification() {
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("远程控制服务正在运行")
.setSmallIcon(android.R.drawable.ic_menu_camera)
.setContentIntent(pendingIntent)
.build();
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"屏幕共享服务",
NotificationManager.IMPORTANCE_LOW);
channel.setDescription("远程控制屏幕采集前台服务");
NotificationManager manager = getSystemService(NotificationManager.class);
manager.createNotificationChannel(channel);
}
}
}

View File

@@ -0,0 +1,34 @@
package com.tt.controlled.signaling;
public class SignalMessage {
private String type;
private String fromDeviceId;
private String toDeviceId;
private String deviceType;
private String payload;
public SignalMessage() {}
public SignalMessage(String type, String fromDeviceId, String toDeviceId, String payload) {
this.type = type;
this.fromDeviceId = fromDeviceId;
this.toDeviceId = toDeviceId;
this.payload = payload;
}
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getFromDeviceId() { return fromDeviceId; }
public void setFromDeviceId(String fromDeviceId) { this.fromDeviceId = fromDeviceId; }
public String getToDeviceId() { return toDeviceId; }
public void setToDeviceId(String toDeviceId) { this.toDeviceId = toDeviceId; }
public String getDeviceType() { return deviceType; }
public void setDeviceType(String deviceType) { this.deviceType = deviceType; }
public String getPayload() { return payload; }
public void setPayload(String payload) { this.payload = payload; }
}

View File

@@ -0,0 +1,117 @@
package com.tt.controlled.signaling;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.google.gson.Gson;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
public class WebSocketClient {
private static final String TAG = "WebSocketClient";
private final String serverUrl;
private final String deviceId;
private final Gson gson = new Gson();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private OkHttpClient client;
private WebSocket webSocket;
private SignalListener listener;
public interface SignalListener {
void onConnected();
void onDisconnected();
void onError(String error);
void onMessage(SignalMessage message);
}
public WebSocketClient(String serverUrl, String deviceId) {
this.serverUrl = serverUrl;
this.deviceId = deviceId;
}
public void setListener(SignalListener listener) {
this.listener = listener;
}
public void connect() {
client = new OkHttpClient.Builder().build();
Request request = new Request.Builder().url(serverUrl).build();
webSocket = client.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket ws, Response response) {
Log.i(TAG, "WebSocket connected");
// 发送注册消息
registerDevice();
mainHandler.post(() -> {
if (listener != null) listener.onConnected();
});
}
@Override
public void onMessage(WebSocket ws, String text) {
Log.d(TAG, "Received: " + text);
try {
SignalMessage message = gson.fromJson(text, SignalMessage.class);
mainHandler.post(() -> {
if (listener != null) listener.onMessage(message);
});
} catch (Exception e) {
Log.e(TAG, "Error parsing message", e);
}
}
@Override
public void onClosed(WebSocket ws, int code, String reason) {
Log.i(TAG, "WebSocket closed: " + reason);
mainHandler.post(() -> {
if (listener != null) listener.onDisconnected();
});
}
@Override
public void onFailure(WebSocket ws, Throwable t, Response response) {
Log.e(TAG, "WebSocket error: " + t.getMessage(), t);
mainHandler.post(() -> {
if (listener != null) listener.onError(t.getMessage());
});
}
});
}
public void sendMessage(SignalMessage message) {
if (webSocket != null) {
String json = gson.toJson(message);
Log.d(TAG, "Sending: " + json);
webSocket.send(json);
}
}
public void disconnect() {
if (webSocket != null) {
webSocket.close(1000, "Disconnecting");
}
if (client != null) {
client.dispatcher().executorService().shutdown();
}
}
private void registerDevice() {
SignalMessage registerMsg = new SignalMessage();
registerMsg.setType("REGISTER");
registerMsg.setFromDeviceId(deviceId);
registerMsg.setDeviceType("CONTROLLED");
sendMessage(registerMsg);
}
public boolean isConnected() {
return webSocket != null;
}
}

View File

@@ -0,0 +1,48 @@
package com.tt.controlled.webrtc;
import android.util.Log;
import org.webrtc.SdpObserver;
import org.webrtc.SessionDescription;
public class AppSdpObserver implements SdpObserver {
private static final String TAG = "AppSdpObserver";
private final SdpEventListener listener;
public interface SdpEventListener {
void onCreateSuccess(SessionDescription sdp);
void onSetSuccess();
void onCreateFailure(String error);
void onSetFailure(String error);
}
public AppSdpObserver(SdpEventListener listener) {
this.listener = listener;
}
@Override
public void onCreateSuccess(SessionDescription sdp) {
Log.d(TAG, "SDP created: " + sdp.type);
if (listener != null) listener.onCreateSuccess(sdp);
}
@Override
public void onSetSuccess() {
Log.d(TAG, "SDP set success");
if (listener != null) listener.onSetSuccess();
}
@Override
public void onCreateFailure(String error) {
Log.e(TAG, "SDP create failure: " + error);
if (listener != null) listener.onCreateFailure(error);
}
@Override
public void onSetFailure(String error) {
Log.e(TAG, "SDP set failure: " + error);
if (listener != null) listener.onSetFailure(error);
}
}

View File

@@ -0,0 +1,77 @@
package com.tt.controlled.webrtc;
import org.webrtc.DataChannel;
import org.webrtc.IceCandidate;
import org.webrtc.MediaStream;
import org.webrtc.PeerConnection;
import org.webrtc.RtpReceiver;
public class PeerObserver implements PeerConnection.Observer {
private static final String TAG = "PeerObserver";
private final PeerEventListener listener;
public interface PeerEventListener {
void onIceCandidate(IceCandidate candidate);
void onIceConnectionChange(PeerConnection.IceConnectionState newState);
void onDataChannel(DataChannel dataChannel);
void onAddStream(MediaStream stream);
void onRemoveStream(MediaStream stream);
}
public PeerObserver(PeerEventListener listener) {
this.listener = listener;
}
@Override
public void onSignalingChange(PeerConnection.SignalingState newState) {
android.util.Log.d(TAG, "Signaling state: " + newState);
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState newState) {
android.util.Log.d(TAG, "ICE connection state: " + newState);
if (listener != null) listener.onIceConnectionChange(newState);
}
@Override
public void onIceConnectionReceivingChange(boolean receiving) {}
@Override
public void onIceGatheringChange(PeerConnection.IceGatheringState newState) {
android.util.Log.d(TAG, "ICE gathering state: " + newState);
}
@Override
public void onIceCandidate(IceCandidate candidate) {
if (listener != null) listener.onIceCandidate(candidate);
}
@Override
public void onIceCandidatesRemoved(IceCandidate[] candidates) {}
@Override
public void onAddStream(MediaStream stream) {
if (listener != null) listener.onAddStream(stream);
}
@Override
public void onRemoveStream(MediaStream stream) {
if (listener != null) listener.onRemoveStream(stream);
}
@Override
public void onDataChannel(DataChannel dataChannel) {
android.util.Log.d(TAG, "Remote data channel received");
if (listener != null) listener.onDataChannel(dataChannel);
}
@Override
public void onRenegotiationNeeded() {
android.util.Log.d(TAG, "Renegotiation needed");
}
@Override
public void onAddTrack(RtpReceiver receiver, MediaStream[] streams) {}
}

View File

@@ -0,0 +1,254 @@
package com.tt.controlled.webrtc;
import android.content.Context;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.tt.controlled.signaling.SignalMessage;
import com.tt.controlled.signaling.WebSocketClient;
import org.webrtc.DataChannel;
import org.webrtc.DefaultVideoEncoderFactory;
import org.webrtc.EglBase;
import org.webrtc.IceCandidate;
import org.webrtc.MediaConstraints;
import org.webrtc.MediaStream;
import org.webrtc.PeerConnection;
import org.webrtc.PeerConnectionFactory;
import org.webrtc.SessionDescription;
import org.webrtc.VideoCapturer;
import org.webrtc.VideoSource;
import org.webrtc.VideoTrack;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class WebRtcClient {
private static final String TAG = "WebRtcClient";
private static final String VIDEO_TRACK_ID = "screen_track";
private static final String STREAM_ID = "screen_stream";
private static final String DATA_CHANNEL_LABEL = "control_channel";
private final Context context;
private final WebSocketClient wsClient;
private final String deviceId;
private final Gson gson = new Gson();
private PeerConnectionFactory peerConnectionFactory;
private PeerConnection peerConnection;
private DataChannel dataChannel;
private VideoTrack videoTrack;
private EglBase eglBase;
private String currentControllerId;
private InputCommandCallback inputCallback;
public interface InputCommandCallback {
void onCommandReceived(String commandJson);
}
public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) {
this.context = context;
this.wsClient = wsClient;
this.deviceId = deviceId;
}
public void setInputCallback(InputCommandCallback callback) {
this.inputCallback = callback;
}
public void initialize(EglBase eglBase) {
this.eglBase = eglBase;
PeerConnectionFactory.InitializationOptions initOptions =
PeerConnectionFactory.InitializationOptions.builder(context)
.setFieldTrials("WebRTC-H264HighProfile/Enabled/")
.createInitializationOptions();
PeerConnectionFactory.initialize(initOptions);
PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
peerConnectionFactory = PeerConnectionFactory.builder()
.setOptions(options)
.setVideoEncoderFactory(new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true))
.createPeerConnectionFactory();
}
public void setVideoCapturer(VideoCapturer capturer) {
VideoSource videoSource = peerConnectionFactory.createVideoSource(false);
capturer.initialize(null, context, videoSource.getCapturerObserver());
capturer.startCapture(1280, 720, 30);
videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource);
}
public void createPeerConnectionAndAnswer(String offerSdp, String controllerId) {
this.currentControllerId = controllerId;
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(new ArrayList<>());
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
PeerObserver peerObserver = new PeerObserver(new PeerObserver.PeerEventListener() {
@Override
public void onIceCandidate(IceCandidate candidate) {
sendIceCandidate(candidate, controllerId);
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState newState) {
Log.d(TAG, "ICE connection state: " + newState);
}
@Override
public void onDataChannel(DataChannel dc) {
Log.d(TAG, "Data channel received: " + dc.label());
handleDataChannel(dc);
}
@Override
public void onAddStream(MediaStream stream) {}
@Override
public void onRemoveStream(MediaStream stream) {}
});
peerConnection = peerConnectionFactory.createPeerConnection(config, peerObserver);
if (videoTrack != null) {
MediaStream stream = peerConnectionFactory.createLocalMediaStream(STREAM_ID);
stream.addTrack(videoTrack);
peerConnection.addStream(stream);
}
// 创建本地 DataChannel
DataChannel.Init dcInit = new DataChannel.Init();
dcInit.ordered = true;
dataChannel = peerConnection.createDataChannel(DATA_CHANNEL_LABEL, dcInit);
handleDataChannel(dataChannel);
// 设置远端 SDP (Offer)
SessionDescription remoteSdp = new SessionDescription(SessionDescription.Type.OFFER, offerSdp);
peerConnection.setRemoteDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
@Override
public void onCreateSuccess(SessionDescription sdp) {}
@Override
public void onSetSuccess() {
// 创建 Answer
MediaConstraints constraints = new MediaConstraints();
peerConnection.createAnswer(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
@Override
public void onCreateSuccess(SessionDescription sdp) {
peerConnection.setLocalDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
@Override
public void onCreateSuccess(SessionDescription s) {}
@Override
public void onSetSuccess() {
sendAnswer(sdp.description, controllerId);
}
@Override
public void onCreateFailure(String error) {}
@Override
public void onSetFailure(String error) {}
}), sdp);
}
@Override
public void onSetSuccess() {}
@Override
public void onCreateFailure(String error) {}
@Override
public void onSetFailure(String error) {}
}), constraints);
}
@Override
public void onCreateFailure(String error) {}
@Override
public void onSetFailure(String error) {}
}), remoteSdp);
}
public void addIceCandidate(IceCandidate candidate) {
if (peerConnection != null) {
peerConnection.addIceCandidate(candidate);
}
}
public void sendInputResponse(String responseJson) {
if (dataChannel != null && dataChannel.state() == DataChannel.State.OPEN) {
DataChannel.Buffer buffer = new DataChannel.Buffer(
ByteBuffer.wrap(responseJson.getBytes(StandardCharsets.UTF_8)), false);
dataChannel.send(buffer);
}
}
public void close() {
if (dataChannel != null) {
dataChannel.close();
dataChannel.dispose();
}
if (peerConnection != null) {
peerConnection.close();
peerConnection.dispose();
}
if (peerConnectionFactory != null) {
peerConnectionFactory.dispose();
}
}
private void handleDataChannel(DataChannel dc) {
dc.registerObserver(new DataChannel.Observer() {
@Override
public void onBufferedAmountChange(long previousAmount) {}
@Override
public void onStateChange() {
Log.d(TAG, "DataChannel state: " + dc.state());
}
@Override
public void onMessage(DataChannel.Buffer buffer) {
byte[] data = new byte[buffer.data.remaining()];
buffer.data.get(data);
String message = new String(data, StandardCharsets.UTF_8);
Log.d(TAG, "DataChannel message: " + message);
if (inputCallback != null) {
inputCallback.onCommandReceived(message);
}
}
});
}
private void sendAnswer(String sdp, String controllerId) {
SignalMessage msg = new SignalMessage();
msg.setType("ANSWER");
msg.setFromDeviceId(deviceId);
msg.setToDeviceId(controllerId);
msg.setDeviceType("CONTROLLED");
JsonObject payload = new JsonObject();
payload.addProperty("sdp", sdp);
msg.setPayload(payload.toString());
wsClient.sendMessage(msg);
}
private void sendIceCandidate(IceCandidate candidate, String controllerId) {
SignalMessage msg = new SignalMessage();
msg.setType("ICE_CANDIDATE");
msg.setFromDeviceId(deviceId);
msg.setToDeviceId(controllerId);
msg.setDeviceType("CONTROLLED");
JsonObject payload = new JsonObject();
payload.addProperty("sdpMid", candidate.sdpMid);
payload.addProperty("sdpMLineIndex", candidate.sdpMLineIndex);
payload.addProperty("candidate", candidate.sdp);
msg.setPayload(payload.toString());
wsClient.sendMessage(msg);
}
}

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="WebRTC 被控端"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="24dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="信令服务器地址:"
android:textSize="14sp"/>
<EditText
android:id="@+id/et_server_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:hint="ws://192.168.1.100:8080/ws/signal"
android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="设备ID:"
android:textSize="14sp"/>
<EditText
android:id="@+id/et_device_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:hint="设备ID"
android:layout_marginBottom="24dp"/>
<TextView
android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="状态: 已停止"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="24dp"/>
<Button
android:id="@+id/btn_start"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="开始屏幕共享"
android:layout_marginBottom="8dp"/>
<Button
android:id="@+id/btn_stop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="停止屏幕共享"
android:enabled="false"/>
</LinearLayout>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">WebRTC被控端</string>
</resources>

View File

@@ -0,0 +1,4 @@
// Top-level build file
plugins {
id 'com.android.application' version '8.1.4' apply false
}

View File

@@ -0,0 +1,3 @@
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Tue Mar 16 15:27:10 CST 2021
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip

172
WebRTCControlled/gradlew vendored Normal file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

84
WebRTCControlled/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,8 @@
## This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Sat Jul 11 15:36:39 CST 2026
sdk.dir=F\:\\AndroidSDK

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WebRTCControlled"
include ':app'