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

View 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'
}

View File

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

View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>

View File

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