init
This commit is contained in:
2
WebRTCControlled/.gitignore
vendored
Normal file
2
WebRTCControlled/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/build/
|
||||||
|
/.idea/
|
||||||
51
WebRTCControlled/app/build.gradle
Normal file
51
WebRTCControlled/app/build.gradle
Normal 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'
|
||||||
|
}
|
||||||
1
WebRTCControlled/app/proguard-rules.pro
vendored
Normal file
1
WebRTCControlled/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
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>
|
||||||
4
WebRTCControlled/build.gradle
Normal file
4
WebRTCControlled/build.gradle
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Top-level build file
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.1.4' apply false
|
||||||
|
}
|
||||||
3
WebRTCControlled/gradle.properties
Normal file
3
WebRTCControlled/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
BIN
WebRTCControlled/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
WebRTCControlled/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
WebRTCControlled/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
WebRTCControlled/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
172
WebRTCControlled/gradlew
vendored
Normal 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
84
WebRTCControlled/gradlew.bat
vendored
Normal 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
|
||||||
8
WebRTCControlled/local.properties
Normal file
8
WebRTCControlled/local.properties
Normal 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
|
||||||
18
WebRTCControlled/settings.gradle
Normal file
18
WebRTCControlled/settings.gradle
Normal 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'
|
||||||
2
WebRTCController/.gitignore
vendored
Normal file
2
WebRTCController/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/build/
|
||||||
|
/.idea/
|
||||||
43
WebRTCController/app/build.gradle
Normal file
43
WebRTCController/app/build.gradle
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.tt.controller'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.tt.controller"
|
||||||
|
minSdk 24
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'com.google.android.material:material:1.11.0'
|
||||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
}
|
||||||
1
WebRTCController/app/proguard-rules.pro
vendored
Normal file
1
WebRTCController/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
26
WebRTCController/app/src/main/AndroidManifest.xml
Normal file
26
WebRTCController/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?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" />
|
||||||
|
|
||||||
|
<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"
|
||||||
|
android:screenOrientation="landscape">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package com.tt.controller;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.tt.controller.signaling.SignalMessage;
|
||||||
|
import com.tt.controller.signaling.WebSocketClient;
|
||||||
|
import com.tt.controller.view.RemoteTouchView;
|
||||||
|
import com.tt.controller.webrtc.WebRtcClient;
|
||||||
|
|
||||||
|
import org.webrtc.EglBase;
|
||||||
|
import org.webrtc.IceCandidate;
|
||||||
|
import org.webrtc.SurfaceViewRenderer;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private static final String TAG = "ControllerMain";
|
||||||
|
|
||||||
|
private EditText etServerUrl;
|
||||||
|
private EditText etDeviceId;
|
||||||
|
private EditText etTargetDeviceId;
|
||||||
|
private Button btnConnect;
|
||||||
|
private Button btnDisconnect;
|
||||||
|
private TextView tvStatus;
|
||||||
|
private SurfaceViewRenderer remoteVideoView;
|
||||||
|
private RemoteTouchView touchOverlay;
|
||||||
|
private LinearLayout controlPanel;
|
||||||
|
private LinearLayout setupPanel;
|
||||||
|
private TextView tvStatusControl;
|
||||||
|
|
||||||
|
private WebSocketClient wsClient;
|
||||||
|
private WebRtcClient webRtcClient;
|
||||||
|
private EglBase eglBase;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
private String myDeviceId;
|
||||||
|
private String targetDeviceId;
|
||||||
|
|
||||||
|
@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);
|
||||||
|
etTargetDeviceId = findViewById(R.id.et_target_device_id);
|
||||||
|
btnConnect = findViewById(R.id.btn_connect);
|
||||||
|
btnDisconnect = findViewById(R.id.btn_disconnect);
|
||||||
|
tvStatus = findViewById(R.id.tv_status);
|
||||||
|
remoteVideoView = findViewById(R.id.remote_video_view);
|
||||||
|
touchOverlay = findViewById(R.id.touch_overlay);
|
||||||
|
controlPanel = findViewById(R.id.control_panel);
|
||||||
|
setupPanel = findViewById(R.id.setup_panel);
|
||||||
|
tvStatusControl = findViewById(R.id.tv_status_control);
|
||||||
|
|
||||||
|
// 默认服务器地址
|
||||||
|
etServerUrl.setText("ws://192.168.1.100:8080/ws/signal");
|
||||||
|
// 生成设备ID
|
||||||
|
myDeviceId = "controller_" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
etDeviceId.setText(myDeviceId);
|
||||||
|
|
||||||
|
btnConnect.setOnClickListener(v -> connectToControlled());
|
||||||
|
btnDisconnect.setOnClickListener(v -> disconnect());
|
||||||
|
|
||||||
|
// 初始化 EGL 和远端视频渲染
|
||||||
|
eglBase = EglBase.create();
|
||||||
|
remoteVideoView.init(eglBase.getEglBaseContext(), null);
|
||||||
|
remoteVideoView.setZOrderMediaOverlay(true);
|
||||||
|
|
||||||
|
// 设置触摸事件
|
||||||
|
touchOverlay.setTouchEventListener(new RemoteTouchView.TouchEventListener() {
|
||||||
|
@Override
|
||||||
|
public void onTouch(float relX, float relY) {
|
||||||
|
sendTouchCommand(relX, relY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSwipe(float relX1, float relY1, float relX2, float relY2, long duration) {
|
||||||
|
sendSwipeCommand(relX1, relY1, relX2, relY2, duration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateUI(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connectToControlled() {
|
||||||
|
String serverUrl = etServerUrl.getText().toString().trim();
|
||||||
|
targetDeviceId = etTargetDeviceId.getText().toString().trim();
|
||||||
|
myDeviceId = etDeviceId.getText().toString().trim();
|
||||||
|
|
||||||
|
if (serverUrl.isEmpty() || targetDeviceId.isEmpty() || myDeviceId.isEmpty()) {
|
||||||
|
Toast.makeText(this, "请填写所有字段", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tvStatus.setText("状态: 正在连接信令服务器...");
|
||||||
|
|
||||||
|
// 初始化 WebSocket
|
||||||
|
wsClient = new WebSocketClient(serverUrl, myDeviceId);
|
||||||
|
wsClient.setListener(new WebSocketClient.SignalListener() {
|
||||||
|
@Override
|
||||||
|
public void onConnected() {
|
||||||
|
tvStatus.setText("状态: 已连接信令服务器,正在发起连接...");
|
||||||
|
// 连接成功后初始化 WebRTC 并创建 Offer
|
||||||
|
initWebRtcAndConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisconnected() {
|
||||||
|
tvStatus.setText("状态: 已断开连接");
|
||||||
|
updateUI(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String error) {
|
||||||
|
tvStatus.setText("状态: 连接错误 - " + error);
|
||||||
|
updateUI(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(SignalMessage message) {
|
||||||
|
handleSignalMessage(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wsClient.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initWebRtcAndConnect() {
|
||||||
|
webRtcClient = new WebRtcClient(this, wsClient, myDeviceId);
|
||||||
|
webRtcClient.initialize(eglBase);
|
||||||
|
webRtcClient.setConnectionListener(new WebRtcClient.ConnectionListener() {
|
||||||
|
@Override
|
||||||
|
public void onConnectionEstablished() {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
tvStatus.setText("状态: 已连接 - 远程控制中");
|
||||||
|
updateUI(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConnectionFailed(String error) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
tvStatus.setText("状态: 连接失败 - " + error);
|
||||||
|
updateUI(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisconnected() {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
tvStatus.setText("状态: 远端已断开");
|
||||||
|
updateUI(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
webRtcClient.createOffer(targetDeviceId, remoteVideoView);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSignalMessage(SignalMessage message) {
|
||||||
|
String type = message.getType();
|
||||||
|
if (type == null) return;
|
||||||
|
|
||||||
|
switch (type.toUpperCase()) {
|
||||||
|
case "ANSWER":
|
||||||
|
handleAnswer(message);
|
||||||
|
break;
|
||||||
|
case "ICE_CANDIDATE":
|
||||||
|
handleIceCandidate(message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleAnswer(SignalMessage message) {
|
||||||
|
try {
|
||||||
|
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
|
||||||
|
String sdp = payload.get("sdp").getAsString();
|
||||||
|
if (webRtcClient != null) {
|
||||||
|
webRtcClient.handleAnswer(sdp);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error parsing answer", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (webRtcClient != null) {
|
||||||
|
webRtcClient.addIceCandidate(iceCandidate);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error parsing ICE candidate", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendTouchCommand(float relX, float relY) {
|
||||||
|
if (webRtcClient != null) {
|
||||||
|
JsonObject cmd = new JsonObject();
|
||||||
|
cmd.addProperty("action", "TOUCH");
|
||||||
|
cmd.addProperty("x", relX);
|
||||||
|
cmd.addProperty("y", relY);
|
||||||
|
webRtcClient.sendControlCommand(cmd.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendSwipeCommand(float relX1, float relY1, float relX2, float relY2, long duration) {
|
||||||
|
if (webRtcClient != null) {
|
||||||
|
JsonObject cmd = new JsonObject();
|
||||||
|
cmd.addProperty("action", "SWIPE");
|
||||||
|
cmd.addProperty("x1", relX1);
|
||||||
|
cmd.addProperty("y1", relY1);
|
||||||
|
cmd.addProperty("x2", relX2);
|
||||||
|
cmd.addProperty("y2", relY2);
|
||||||
|
cmd.addProperty("duration", duration);
|
||||||
|
webRtcClient.sendControlCommand(cmd.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void disconnect() {
|
||||||
|
if (webRtcClient != null) {
|
||||||
|
webRtcClient.close();
|
||||||
|
webRtcClient = null;
|
||||||
|
}
|
||||||
|
if (wsClient != null) {
|
||||||
|
wsClient.disconnect();
|
||||||
|
wsClient = null;
|
||||||
|
}
|
||||||
|
remoteVideoView.clearImage();
|
||||||
|
updateUI(false);
|
||||||
|
tvStatus.setText("状态: 已停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateUI(boolean connected) {
|
||||||
|
setupPanel.setVisibility(connected ? View.GONE : View.VISIBLE);
|
||||||
|
controlPanel.setVisibility(connected ? View.VISIBLE : View.GONE);
|
||||||
|
btnConnect.setEnabled(!connected);
|
||||||
|
btnDisconnect.setEnabled(connected);
|
||||||
|
if (tvStatusControl != null) {
|
||||||
|
tvStatusControl.setText(connected ? "远程控制中" : "未连接");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
disconnect();
|
||||||
|
if (eglBase != null) {
|
||||||
|
eglBase.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.tt.controller.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,116 @@
|
|||||||
|
package com.tt.controller.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("CONTROLLER");
|
||||||
|
sendMessage(registerMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConnected() {
|
||||||
|
return webSocket != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.tt.controller.view;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.SurfaceView;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义SurfaceView,用于捕获触摸事件并转换为远端输入指令。
|
||||||
|
* 触摸坐标会被转换为相对于视图的百分比坐标(0.0-1.0),
|
||||||
|
* 以便在不同分辨率的设备间进行坐标换算。
|
||||||
|
*/
|
||||||
|
public class RemoteTouchView extends SurfaceView {
|
||||||
|
|
||||||
|
private static final String TAG = "RemoteTouchView";
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
private TouchEventListener listener;
|
||||||
|
|
||||||
|
private float lastTouchX;
|
||||||
|
private float lastTouchY;
|
||||||
|
private long swipeStartTime;
|
||||||
|
|
||||||
|
public interface TouchEventListener {
|
||||||
|
void onTouch(float relX, float relY);
|
||||||
|
void onSwipe(float relX1, float relY1, float relX2, float relY2, long duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RemoteTouchView(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RemoteTouchView(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RemoteTouchView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTouchEventListener(TouchEventListener listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(MotionEvent event) {
|
||||||
|
if (listener == null) return super.onTouchEvent(event);
|
||||||
|
|
||||||
|
float relX = event.getX() / getWidth();
|
||||||
|
float relY = event.getY() / getHeight();
|
||||||
|
|
||||||
|
// 确保坐标在 [0, 1] 范围内
|
||||||
|
relX = Math.max(0, Math.min(1, relX));
|
||||||
|
relY = Math.max(0, Math.min(1, relY));
|
||||||
|
|
||||||
|
switch (event.getActionMasked()) {
|
||||||
|
case MotionEvent.ACTION_DOWN:
|
||||||
|
lastTouchX = relX;
|
||||||
|
lastTouchY = relY;
|
||||||
|
swipeStartTime = System.currentTimeMillis();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case MotionEvent.ACTION_MOVE:
|
||||||
|
// 实时更新坐标(可选:用于拖拽效果)
|
||||||
|
lastTouchX = relX;
|
||||||
|
lastTouchY = relY;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
long duration = System.currentTimeMillis() - swipeStartTime;
|
||||||
|
float dx = Math.abs(relX - lastTouchX);
|
||||||
|
float dy = Math.abs(relY - lastTouchY);
|
||||||
|
|
||||||
|
if (duration < 200 && dx < 0.02 && dy < 0.02) {
|
||||||
|
// 点击事件
|
||||||
|
listener.onTouch(lastTouchX, lastTouchY);
|
||||||
|
Log.d(TAG, "Touch at: " + lastTouchX + ", " + lastTouchY);
|
||||||
|
} else {
|
||||||
|
// 滑动事件
|
||||||
|
listener.onSwipe(lastTouchX, lastTouchY, relX, relY, duration);
|
||||||
|
Log.d(TAG, "Swipe from (" + lastTouchX + "," + lastTouchY + ") to (" + relX + "," + relY + ")");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onTouchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String createTouchCommand(float relX, float relY) {
|
||||||
|
JsonObject cmd = new JsonObject();
|
||||||
|
cmd.addProperty("action", "TOUCH");
|
||||||
|
cmd.addProperty("x", relX);
|
||||||
|
cmd.addProperty("y", relY);
|
||||||
|
return cmd.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String createSwipeCommand(float relX1, float relY1, float relX2, float relY2, long duration) {
|
||||||
|
JsonObject cmd = new JsonObject();
|
||||||
|
cmd.addProperty("action", "SWIPE");
|
||||||
|
cmd.addProperty("x1", relX1);
|
||||||
|
cmd.addProperty("y1", relY1);
|
||||||
|
cmd.addProperty("x2", relX2);
|
||||||
|
cmd.addProperty("y2", relY2);
|
||||||
|
cmd.addProperty("duration", duration);
|
||||||
|
return cmd.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.tt.controller.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.controller.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, "Data channel received: " + dataChannel.label());
|
||||||
|
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,35 @@
|
|||||||
|
package com.tt.controller.webrtc;
|
||||||
|
|
||||||
|
import org.webrtc.EglBase;
|
||||||
|
import org.webrtc.RendererCommon;
|
||||||
|
import org.webrtc.SurfaceViewRenderer;
|
||||||
|
import org.webrtc.VideoFrame;
|
||||||
|
import org.webrtc.VideoSink;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端视频流渲染器,将WebRTC视频帧渲染到SurfaceViewRenderer。
|
||||||
|
*/
|
||||||
|
public class VideoRenderer implements VideoSink {
|
||||||
|
|
||||||
|
private static final String TAG = "VideoRenderer";
|
||||||
|
private final SurfaceViewRenderer surfaceViewRenderer;
|
||||||
|
|
||||||
|
public VideoRenderer(SurfaceViewRenderer surfaceViewRenderer) {
|
||||||
|
this.surfaceViewRenderer = surfaceViewRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrame(VideoFrame frame) {
|
||||||
|
surfaceViewRenderer.onFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void init(EglBase eglBase) {
|
||||||
|
surfaceViewRenderer.init(eglBase.getEglBaseContext(), null);
|
||||||
|
surfaceViewRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
|
||||||
|
surfaceViewRenderer.setEnableHardwareScaler(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void release() {
|
||||||
|
surfaceViewRenderer.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package com.tt.controller.webrtc;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.tt.controller.signaling.SignalMessage;
|
||||||
|
import com.tt.controller.signaling.WebSocketClient;
|
||||||
|
|
||||||
|
import org.webrtc.DataChannel;
|
||||||
|
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.SurfaceViewRenderer;
|
||||||
|
import org.webrtc.VideoTrack;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
public class WebRtcClient {
|
||||||
|
|
||||||
|
private static final String TAG = "WebRtcClient";
|
||||||
|
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 EglBase eglBase;
|
||||||
|
private String currentControlledId;
|
||||||
|
private VideoRenderer videoRenderer;
|
||||||
|
|
||||||
|
public interface ConnectionListener {
|
||||||
|
void onConnectionEstablished();
|
||||||
|
void onConnectionFailed(String error);
|
||||||
|
void onDisconnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConnectionListener connectionListener;
|
||||||
|
|
||||||
|
public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) {
|
||||||
|
this.context = context;
|
||||||
|
this.wsClient = wsClient;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConnectionListener(ConnectionListener listener) {
|
||||||
|
this.connectionListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void initialize(EglBase eglBase) {
|
||||||
|
this.eglBase = eglBase;
|
||||||
|
PeerConnectionFactory.InitializationOptions initOptions =
|
||||||
|
PeerConnectionFactory.InitializationOptions.builder(context)
|
||||||
|
.createInitializationOptions();
|
||||||
|
PeerConnectionFactory.initialize(initOptions);
|
||||||
|
|
||||||
|
PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
|
||||||
|
peerConnectionFactory = PeerConnectionFactory.builder()
|
||||||
|
.setOptions(options)
|
||||||
|
.createPeerConnectionFactory();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void createOffer(String controlledId, SurfaceViewRenderer remoteRenderer) {
|
||||||
|
this.currentControlledId = controlledId;
|
||||||
|
|
||||||
|
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, controlledId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceConnectionChange(PeerConnection.IceConnectionState newState) {
|
||||||
|
Log.d(TAG, "ICE connection state: " + newState);
|
||||||
|
if (newState == PeerConnection.IceConnectionState.CONNECTED) {
|
||||||
|
if (connectionListener != null) connectionListener.onConnectionEstablished();
|
||||||
|
} else if (newState == PeerConnection.IceConnectionState.FAILED ||
|
||||||
|
newState == PeerConnection.IceConnectionState.DISCONNECTED) {
|
||||||
|
if (connectionListener != null) connectionListener.onDisconnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDataChannel(DataChannel dc) {
|
||||||
|
Log.d(TAG, "Data channel received: " + dc.label());
|
||||||
|
handleDataChannel(dc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAddStream(MediaStream stream) {
|
||||||
|
if (!stream.videoTracks.isEmpty()) {
|
||||||
|
VideoTrack videoTrack = stream.videoTracks.get(0);
|
||||||
|
if (videoRenderer != null) {
|
||||||
|
videoTrack.addSink(videoRenderer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRemoveStream(MediaStream stream) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
peerConnection = peerConnectionFactory.createPeerConnection(config, peerObserver);
|
||||||
|
|
||||||
|
// 创建 DataChannel
|
||||||
|
DataChannel.Init dcInit = new DataChannel.Init();
|
||||||
|
dcInit.ordered = true;
|
||||||
|
dataChannel = peerConnection.createDataChannel(DATA_CHANNEL_LABEL, dcInit);
|
||||||
|
handleDataChannel(dataChannel);
|
||||||
|
|
||||||
|
// 初始化远端视频渲染器
|
||||||
|
if (remoteRenderer != null) {
|
||||||
|
videoRenderer = new VideoRenderer(remoteRenderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Offer
|
||||||
|
MediaConstraints constraints = new MediaConstraints();
|
||||||
|
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"));
|
||||||
|
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"));
|
||||||
|
|
||||||
|
peerConnection.createOffer(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() {
|
||||||
|
sendOffer(sdp.description, controlledId);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void onCreateFailure(String error) {}
|
||||||
|
@Override
|
||||||
|
public void onSetFailure(String error) {}
|
||||||
|
}), sdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSetSuccess() {}
|
||||||
|
@Override
|
||||||
|
public void onCreateFailure(String error) {
|
||||||
|
if (connectionListener != null) connectionListener.onConnectionFailed(error);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void onSetFailure(String error) {}
|
||||||
|
}), constraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handleAnswer(String answerSdp) {
|
||||||
|
if (peerConnection != null) {
|
||||||
|
SessionDescription remoteSdp = new SessionDescription(SessionDescription.Type.ANSWER, answerSdp);
|
||||||
|
peerConnection.setRemoteDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
|
||||||
|
@Override
|
||||||
|
public void onCreateSuccess(SessionDescription sdp) {}
|
||||||
|
@Override
|
||||||
|
public void onSetSuccess() {
|
||||||
|
Log.d(TAG, "Remote answer set successfully");
|
||||||
|
}
|
||||||
|
@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 sendControlCommand(String commandJson) {
|
||||||
|
if (dataChannel != null && dataChannel.state() == DataChannel.State.OPEN) {
|
||||||
|
DataChannel.Buffer buffer = new DataChannel.Buffer(
|
||||||
|
ByteBuffer.wrap(commandJson.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendOffer(String sdp, String controlledId) {
|
||||||
|
SignalMessage msg = new SignalMessage();
|
||||||
|
msg.setType("OFFER");
|
||||||
|
msg.setFromDeviceId(deviceId);
|
||||||
|
msg.setToDeviceId(controlledId);
|
||||||
|
msg.setDeviceType("CONTROLLER");
|
||||||
|
|
||||||
|
JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("sdp", sdp);
|
||||||
|
msg.setPayload(payload.toString());
|
||||||
|
|
||||||
|
wsClient.sendMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendIceCandidate(IceCandidate candidate, String controlledId) {
|
||||||
|
SignalMessage msg = new SignalMessage();
|
||||||
|
msg.setType("ICE_CANDIDATE");
|
||||||
|
msg.setFromDeviceId(deviceId);
|
||||||
|
msg.setToDeviceId(controlledId);
|
||||||
|
msg.setDeviceType("CONTROLLER");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
WebRTCController/app/src/main/res/layout/activity_main.xml
Normal file
124
WebRTCController/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<!-- 设置面板 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/setup_panel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<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="16dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="目标被控设备ID:"
|
||||||
|
android:textSize="14sp"/>
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/et_target_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_connect"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="连接被控设备"
|
||||||
|
android:layout_marginBottom="8dp"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_disconnect"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="断开连接"
|
||||||
|
android:enabled="false"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 控制面板(连接后显示) -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/control_panel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<!-- 远端视频渲染 -->
|
||||||
|
<org.webrtc.SurfaceViewRenderer
|
||||||
|
android:id="@+id/remote_video_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"/>
|
||||||
|
|
||||||
|
<!-- 触摸控制层(覆盖在视频上方) -->
|
||||||
|
<com.tt.controller.view.RemoteTouchView
|
||||||
|
android:id="@+id/touch_overlay"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@android:color/transparent"/>
|
||||||
|
|
||||||
|
<!-- 顶部状态栏 -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_status_control"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="top|start"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:background="#80000000"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:text="远程控制中"
|
||||||
|
android:textSize="14sp"/>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
4
WebRTCController/app/src/main/res/values/strings.xml
Normal file
4
WebRTCController/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>
|
||||||
4
WebRTCController/build.gradle
Normal file
4
WebRTCController/build.gradle
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Top-level build file
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.1.4' apply false
|
||||||
|
}
|
||||||
3
WebRTCController/gradle.properties
Normal file
3
WebRTCController/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
8
WebRTCController/local.properties
Normal file
8
WebRTCController/local.properties
Normal 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:50 CST 2026
|
||||||
|
sdk.dir=F\:\\AndroidSDK
|
||||||
18
WebRTCController/settings.gradle
Normal file
18
WebRTCController/settings.gradle
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "WebRTCController"
|
||||||
|
include ':app'
|
||||||
2
WebRTCSignalServer/.gitignore
vendored
Normal file
2
WebRTCSignalServer/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target/
|
||||||
|
/.idea/
|
||||||
52
WebRTCSignalServer/pom.xml
Normal file
52
WebRTCSignalServer/pom.xml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.0</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.tt</groupId>
|
||||||
|
<artifactId>webrtc-signal-server</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>WebRTCSignalServer</name>
|
||||||
|
<description>WebRTC Signaling Server using WebSocket</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tt.signaling;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class SignalServerApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(SignalServerApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.tt.signaling.config;
|
||||||
|
|
||||||
|
import com.tt.signaling.handler.SignalWebSocketHandler;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocket
|
||||||
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
|
private final SignalWebSocketHandler signalWebSocketHandler;
|
||||||
|
|
||||||
|
public WebSocketConfig(SignalWebSocketHandler signalWebSocketHandler) {
|
||||||
|
this.signalWebSocketHandler = signalWebSocketHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
registry.addHandler(signalWebSocketHandler, "/ws/signal")
|
||||||
|
.setAllowedOrigins("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.tt.signaling.handler;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.tt.signaling.manager.SessionManager;
|
||||||
|
import com.tt.signaling.model.DeviceType;
|
||||||
|
import com.tt.signaling.model.SignalMessage;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.CloseStatus;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SignalWebSocketHandler extends TextWebSocketHandler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SignalWebSocketHandler.class);
|
||||||
|
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
private final SessionManager sessionManager;
|
||||||
|
|
||||||
|
public SignalWebSocketHandler(SessionManager sessionManager) {
|
||||||
|
this.sessionManager = sessionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||||
|
logger.info("New WebSocket connection: {}", session.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||||
|
String payload = message.getPayload();
|
||||||
|
logger.debug("Received message: {}", payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
SignalMessage signalMessage = objectMapper.readValue(payload, SignalMessage.class);
|
||||||
|
String type = signalMessage.getType();
|
||||||
|
|
||||||
|
if (type == null) {
|
||||||
|
logger.warn("Message type is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type.toUpperCase()) {
|
||||||
|
case "REGISTER":
|
||||||
|
handleRegister(session, signalMessage);
|
||||||
|
break;
|
||||||
|
case "DEVICE_LIST":
|
||||||
|
handleDeviceList(session, signalMessage);
|
||||||
|
break;
|
||||||
|
case "OFFER":
|
||||||
|
case "ANSWER":
|
||||||
|
case "ICE_CANDIDATE":
|
||||||
|
case "CONTROL_COMMAND":
|
||||||
|
forwardMessage(signalMessage);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 其他消息类型直接转发
|
||||||
|
forwardMessage(signalMessage);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error handling message: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
||||||
|
logger.info("WebSocket connection closed: {} ({})", session.getId(), status);
|
||||||
|
sessionManager.unregisterSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||||
|
logger.error("Transport error on session {}: {}", session.getId(), exception.getMessage());
|
||||||
|
sessionManager.unregisterSession(session);
|
||||||
|
if (session.isOpen()) {
|
||||||
|
session.close(CloseStatus.SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRegister(WebSocketSession session, SignalMessage message) {
|
||||||
|
String deviceId = message.getFromDeviceId();
|
||||||
|
String deviceTypeStr = message.getDeviceType();
|
||||||
|
|
||||||
|
if (deviceId == null || deviceTypeStr == null) {
|
||||||
|
logger.warn("Invalid REGISTER message: missing deviceId or deviceType");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DeviceType deviceType = DeviceType.valueOf(deviceTypeStr.toUpperCase());
|
||||||
|
sessionManager.registerDevice(deviceId, deviceType, session);
|
||||||
|
|
||||||
|
// 回复注册成功
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("type", "REGISTER_SUCCESS");
|
||||||
|
response.put("deviceId", deviceId);
|
||||||
|
sendToSession(session, response);
|
||||||
|
|
||||||
|
logger.info("Device {} registered as {}", deviceId, deviceType);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
logger.warn("Invalid device type: {}", deviceTypeStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleDeviceList(WebSocketSession session, SignalMessage message) {
|
||||||
|
List<String> controllers = sessionManager.getDevicesByType(DeviceType.CONTROLLER);
|
||||||
|
List<String> controlled = sessionManager.getDevicesByType(DeviceType.CONTROLLED);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("type", "DEVICE_LIST");
|
||||||
|
response.put("controllers", controllers);
|
||||||
|
response.put("controlled", controlled);
|
||||||
|
sendToSession(session, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void forwardMessage(SignalMessage message) {
|
||||||
|
String toDeviceId = message.getToDeviceId();
|
||||||
|
if (toDeviceId == null) {
|
||||||
|
logger.warn("Cannot forward message: toDeviceId is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WebSocketSession targetSession = sessionManager.getSession(toDeviceId);
|
||||||
|
if (targetSession == null || !targetSession.isOpen()) {
|
||||||
|
logger.warn("Target device {} is not online", toDeviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String jsonMessage = objectMapper.writeValueAsString(message);
|
||||||
|
targetSession.sendMessage(new TextMessage(jsonMessage));
|
||||||
|
logger.debug("Forwarded {} from {} to {}", message.getType(), message.getFromDeviceId(), toDeviceId);
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Error forwarding message to {}: {}", toDeviceId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendToSession(WebSocketSession session, Object data) {
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(data);
|
||||||
|
session.sendMessage(new TextMessage(json));
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Error sending message to session {}: {}", session.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.tt.signaling.manager;
|
||||||
|
|
||||||
|
import com.tt.signaling.model.DeviceType;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SessionManager {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SessionManager.class);
|
||||||
|
|
||||||
|
// deviceId -> WebSocketSession
|
||||||
|
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
// deviceId -> deviceType
|
||||||
|
private final Map<String, DeviceType> deviceTypes = new ConcurrentHashMap<>();
|
||||||
|
// session.getId() -> deviceId (用于断开连接时清理)
|
||||||
|
private final Map<String, String> sessionToDevice = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public void registerDevice(String deviceId, DeviceType deviceType, WebSocketSession session) {
|
||||||
|
sessions.put(deviceId, session);
|
||||||
|
deviceTypes.put(deviceId, deviceType);
|
||||||
|
sessionToDevice.put(session.getId(), deviceId);
|
||||||
|
logger.info("Device registered: {} ({})", deviceId, deviceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void unregisterSession(WebSocketSession session) {
|
||||||
|
String deviceId = sessionToDevice.remove(session.getId());
|
||||||
|
if (deviceId != null) {
|
||||||
|
sessions.remove(deviceId);
|
||||||
|
deviceTypes.remove(deviceId);
|
||||||
|
logger.info("Device unregistered: {}", deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebSocketSession getSession(String deviceId) {
|
||||||
|
return sessions.get(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDeviceOnline(String deviceId) {
|
||||||
|
WebSocketSession session = sessions.get(deviceId);
|
||||||
|
return session != null && session.isOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getDevicesByType(DeviceType type) {
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, DeviceType> entry : deviceTypes.entrySet()) {
|
||||||
|
if (entry.getValue() == type && isDeviceOnline(entry.getKey())) {
|
||||||
|
result.add(entry.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getAllOnlineDevices() {
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, WebSocketSession> entry : sessions.entrySet()) {
|
||||||
|
if (entry.getValue().isOpen()) {
|
||||||
|
result.add(entry.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.tt.signaling.model;
|
||||||
|
|
||||||
|
public enum DeviceType {
|
||||||
|
CONTROLLER,
|
||||||
|
CONTROLLED
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.tt.signaling.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class SignalMessage {
|
||||||
|
|
||||||
|
private String type; // OFFER, ANSWER, ICE_CANDIDATE, CONTROL_COMMAND, REGISTER, DEVICE_LIST
|
||||||
|
private String fromDeviceId; // 发送方设备ID
|
||||||
|
private String toDeviceId; // 目标设备ID
|
||||||
|
private String deviceType; // CONTROLLER 或 CONTROLLED
|
||||||
|
private String payload; // JSON格式的信令数据(SDP/ICE/控制指令)
|
||||||
|
|
||||||
|
public SignalMessage() {}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
11
WebRTCSignalServer/src/main/resources/application.yml
Normal file
11
WebRTCSignalServer/src/main/resources/application.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: webrtc-signal-server
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.tt.signaling: DEBUG
|
||||||
|
org.springframework.web.socket: DEBUG
|
||||||
Reference in New Issue
Block a user