init
This commit is contained in:
34
WebRTCControlled/app/src/main/AndroidManifest.xml
Normal file
34
WebRTCControlled/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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 ? "状态: 运行中" : "状态: 已停止");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
67
WebRTCControlled/app/src/main/res/layout/activity_main.xml
Normal file
67
WebRTCControlled/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
4
WebRTCControlled/app/src/main/res/values/strings.xml
Normal file
4
WebRTCControlled/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">WebRTC被控端</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user