feat: 交换信令
This commit is contained in:
@@ -3,13 +3,13 @@ apply plugin: 'com.android.application'
|
|||||||
android {
|
android {
|
||||||
namespace "com.ttstd.remoteclient"
|
namespace "com.ttstd.remoteclient"
|
||||||
|
|
||||||
compileSdkVersion 33
|
compileSdkVersion 36
|
||||||
// buildToolsVersion "36.0.0"
|
// buildToolsVersion "36.0.0"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.ttstd.remoteclient"
|
applicationId "com.ttstd.remoteclient"
|
||||||
minSdkVersion 24
|
minSdkVersion 24
|
||||||
targetSdkVersion 33
|
targetSdkVersion 36
|
||||||
|
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
@@ -50,6 +50,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation 'androidx.activity:activity-ktx:1.13.0'
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||||
|
|
||||||
// 添加 Kotlin 标准库
|
// 添加 Kotlin 标准库
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.ttstd.remoteclient">
|
package="com.ttstd.remoteclient">
|
||||||
|
|
||||||
<!-- 网络权限 -->
|
<!-- 网络权限 -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" /> <!-- 前台服务权限 -->
|
||||||
<!-- 前台服务权限 -->
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- 悬浮窗权限(可选,用于显示服务状态) -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<!-- 悬浮窗权限(可选,用于显示服务状态) -->
|
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.POST_NOTIFICATIONS"
|
android:name="android.permission.POST_NOTIFICATIONS"
|
||||||
@@ -24,17 +22,19 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
|
<activity
|
||||||
|
android:name=".activity.display.DisplayActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activity.main.MainActivity"
|
android:name=".activity.main.MainActivity"
|
||||||
android:theme="@style/AppThemeFitsSystem"
|
android:exported="true"
|
||||||
android:exported="true">
|
android:theme="@style/AppThemeFitsSystem">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ttstd.remoteclient.activity.display;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
import androidx.activity.EdgeToEdge;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.core.graphics.Insets;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
|
|
||||||
|
import com.ttstd.remoteclient.R;
|
||||||
|
|
||||||
|
public class DisplayActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
EdgeToEdge.enable(this);
|
||||||
|
setContentView(R.layout.activity_display);
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
|
||||||
|
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||||
|
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,13 +15,13 @@ import androidx.core.content.ContextCompat;
|
|||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.reflect.TypeToken;
|
|
||||||
import com.ttstd.remoteclient.R;
|
import com.ttstd.remoteclient.R;
|
||||||
import com.ttstd.remoteclient.base.mvvm.BaseMvvmActivity;
|
import com.ttstd.remoteclient.base.mvvm.BaseMvvmActivity;
|
||||||
|
import com.ttstd.remoteclient.bean.SignalingMessage;
|
||||||
|
import com.ttstd.remoteclient.bean.WebRTCMessage;
|
||||||
import com.ttstd.remoteclient.databinding.ActivityMainBinding;
|
import com.ttstd.remoteclient.databinding.ActivityMainBinding;
|
||||||
import com.ttstd.remoteclient.gson.GsonUtils;
|
import com.ttstd.remoteclient.manager.WebSocketCallback;
|
||||||
import com.ttstd.remoteclient.manager.SignalingWebSocketClient;
|
import com.ttstd.remoteclient.manager.WebSocketManager;
|
||||||
import com.ttstd.remoteclient.utils.SystemUtils;
|
|
||||||
|
|
||||||
import org.webrtc.DataChannel;
|
import org.webrtc.DataChannel;
|
||||||
import org.webrtc.DefaultVideoDecoderFactory;
|
import org.webrtc.DefaultVideoDecoderFactory;
|
||||||
@@ -37,28 +37,36 @@ import org.webrtc.SdpObserver;
|
|||||||
import org.webrtc.SessionDescription;
|
import org.webrtc.SessionDescription;
|
||||||
import org.webrtc.VideoTrack;
|
import org.webrtc.VideoTrack;
|
||||||
|
|
||||||
import java.lang.reflect.Type;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBinding> implements SignalingWebSocketClient.SignalingListener {
|
import okio.ByteString;
|
||||||
|
|
||||||
|
public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBinding> implements WebSocketCallback {
|
||||||
private static final String TAG = "MainActivity";
|
private static final String TAG = "MainActivity";
|
||||||
|
|
||||||
|
private WebSocketManager mWebSocketManager;
|
||||||
|
|
||||||
private static final int NETWORK_REQUEST_CODE = 1001;
|
private static final int NETWORK_REQUEST_CODE = 1001;
|
||||||
|
|
||||||
// ICE服务器配置(必须,用于NAT穿透,公共免费ICE服务器)
|
// ICE服务器配置(用于NAT穿透)
|
||||||
private static final List<PeerConnection.IceServer> ICE_SERVERS = new ArrayList<PeerConnection.IceServer>() {{
|
private static final List<PeerConnection.IceServer> ICE_SERVERS = new ArrayList<PeerConnection.IceServer>() {{
|
||||||
add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer());
|
add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer());
|
||||||
|
add(PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer());
|
||||||
|
add(PeerConnection.IceServer.builder("stun:stun2.l.google.com:19302").createIceServer());
|
||||||
add(PeerConnection.IceServer.builder("stun:175.178.213.60:3478").createIceServer());
|
add(PeerConnection.IceServer.builder("stun:175.178.213.60:3478").createIceServer());
|
||||||
}};
|
}};
|
||||||
|
|
||||||
// WebRTC 核心对象(核心引擎)
|
|
||||||
private PeerConnectionFactory peerConnectionFactory;
|
private PeerConnectionFactory peerConnectionFactory;
|
||||||
private PeerConnection peerConnection;
|
private PeerConnection peerConnection;
|
||||||
private EglBase rootEglBase;
|
private EglBase rootEglBase;
|
||||||
|
|
||||||
private SignalingWebSocketClient signalingClient;
|
|
||||||
private String mRemoteUserId;
|
private String mRemoteUserId;
|
||||||
|
private boolean mIsWebRTCReady = false;
|
||||||
|
|
||||||
|
private final List<IceCandidate> mPendingIceCandidates = new ArrayList<>();
|
||||||
|
|
||||||
|
private final Gson mGson = new Gson();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean setNightMode() {
|
public boolean setNightMode() {
|
||||||
@@ -80,32 +88,44 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void initView() {
|
protected void initView() {
|
||||||
// 申请权限
|
|
||||||
requestPermissions();
|
requestPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void initData() {
|
protected void initData() {
|
||||||
|
mWebSocketManager = WebSocketManager.getInstance();
|
||||||
|
mWebSocketManager.addCallback(this);
|
||||||
|
|
||||||
|
if (mWebSocketManager.isConnected()) {
|
||||||
|
mViewDataBinding.btConnect.setEnabled(false);
|
||||||
|
} else {
|
||||||
|
mViewDataBinding.btConnect.setEnabled(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
if (requestCode == NETWORK_REQUEST_CODE) {
|
if (requestCode == NETWORK_REQUEST_CODE) {
|
||||||
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
initWebRTC();
|
initWebRTC();
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this, "权限被拒绝,无法使用屏幕共享功能", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "权限被拒绝,无法使用相关功能", Toast.LENGTH_SHORT).show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 权限申请逻辑
|
|
||||||
private void requestPermissions() {
|
private void requestPermissions() {
|
||||||
String[] permissions = {Manifest.permission.INTERNET, Manifest.permission.ACCESS_NETWORK_STATE};
|
String[] permissions = {Manifest.permission.INTERNET, Manifest.permission.ACCESS_NETWORK_STATE};
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
if (ContextCompat.checkSelfPermission(this, permissions[0]) != PackageManager.PERMISSION_GRANTED) {
|
boolean allGranted = true;
|
||||||
|
for (String p : permissions) {
|
||||||
|
if (ContextCompat.checkSelfPermission(this, p) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
allGranted = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!allGranted) {
|
||||||
ActivityCompat.requestPermissions(this, permissions, NETWORK_REQUEST_CODE);
|
ActivityCompat.requestPermissions(this, permissions, NETWORK_REQUEST_CODE);
|
||||||
} else {
|
} else {
|
||||||
initWebRTC();
|
initWebRTC();
|
||||||
@@ -115,388 +135,466 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======================== 核心:初始化WebRTC引擎 ========================
|
// ======================== 初始化WebRTC引擎 ========================
|
||||||
private void initWebRTC() {
|
private void initWebRTC() {
|
||||||
Log.e(TAG, "initWebRTC: ");
|
Log.e(TAG, "initWebRTC");
|
||||||
// 1. 初始化EGL环境(WebRTC渲染必须)
|
if (rootEglBase == null) {
|
||||||
rootEglBase = EglBase.create();
|
rootEglBase = EglBase.create();
|
||||||
// 2. 初始化渲染控件
|
|
||||||
mViewDataBinding.remoteVideoView.init(rootEglBase.getEglBaseContext(), null);
|
|
||||||
mViewDataBinding.remoteVideoView.setMirror(false); // 屏幕共享不需要镜像
|
|
||||||
mViewDataBinding.remoteVideoView.setEnableHardwareScaler(true); // 硬件缩放,提升性能
|
|
||||||
|
|
||||||
// 3. 初始化PeerConnectionFactory配置
|
|
||||||
PeerConnectionFactory.InitializationOptions initOptions = PeerConnectionFactory.InitializationOptions
|
|
||||||
.builder(this)
|
|
||||||
.setEnableInternalTracer(true)
|
|
||||||
.createInitializationOptions();
|
|
||||||
PeerConnectionFactory.initialize(initOptions);
|
|
||||||
|
|
||||||
// 4. 创建PeerConnectionFactory(WebRTC核心工厂)
|
|
||||||
PeerConnectionFactory.Options factoryOptions = new PeerConnectionFactory.Options();
|
|
||||||
peerConnectionFactory = PeerConnectionFactory.builder()
|
|
||||||
.setOptions(factoryOptions)
|
|
||||||
.setVideoDecoderFactory(new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext()))
|
|
||||||
.setVideoEncoderFactory(new DefaultVideoEncoderFactory(rootEglBase.getEglBaseContext(), true, true))
|
|
||||||
.createPeerConnectionFactory();
|
|
||||||
|
|
||||||
// 5. 创建PeerConnection(P2P连接核心对象,信令+数据传输都靠它)
|
|
||||||
createPeerConnection();
|
|
||||||
mViewDataBinding.tvStatus.setText("WebRTC初始化完成,等待信令协商...");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 创建P2P连接对象 ========================
|
|
||||||
private void createPeerConnection() {
|
|
||||||
Log.e(TAG, "createPeerConnection: ");
|
|
||||||
PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(ICE_SERVERS);
|
|
||||||
peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, new PeerConnection.Observer() {
|
|
||||||
// 1. ICE候选地址收集回调:收集到本地ICE地址,通过信令发送给远端
|
|
||||||
@Override
|
|
||||||
public void onIceCandidate(IceCandidate iceCandidate) {
|
|
||||||
Log.e(TAG, "onIceCandidate: ");
|
|
||||||
runOnUiThread(() -> mViewDataBinding.tvStatus.setText("收集到ICE候选地址,发送给远端..."));
|
|
||||||
// TODO: 这里将 iceCandidate 转换成JSON,通过你的信令服务器发送给远端设备
|
|
||||||
sendIceCandidateToRemote(iceCandidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. ICE连接状态变化回调
|
|
||||||
@Override
|
|
||||||
public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
|
|
||||||
Log.e(TAG, "onIceConnectionChange: ");
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
mViewDataBinding.tvStatus.setText("连接状态:" + iceConnectionState.name());
|
|
||||||
if (iceConnectionState == PeerConnection.IceConnectionState.CONNECTED) {
|
|
||||||
mViewDataBinding.tvStatus.setText("✅ 已成功连接,正在接收远程屏幕...");
|
|
||||||
} else if (iceConnectionState == PeerConnection.IceConnectionState.DISCONNECTED) {
|
|
||||||
mViewDataBinding.tvStatus.setText("❌ 连接断开");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 收到远端媒体流回调【核心】:收到远程屏幕流,渲染到控件上
|
|
||||||
@Override
|
|
||||||
public void onAddStream(MediaStream mediaStream) {
|
|
||||||
Log.e(TAG, "onAddStream: ");
|
|
||||||
runOnUiThread(() -> mViewDataBinding.tvStatus.setText("✅ 收到远程屏幕流,开始渲染..."));
|
|
||||||
// 获取视频轨道,添加到渲染控件
|
|
||||||
VideoTrack videoTrack = mediaStream.videoTracks.get(0);
|
|
||||||
videoTrack.addSink(mViewDataBinding.remoteVideoView);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他生命周期回调,默认实现即可
|
|
||||||
@Override
|
|
||||||
public void onSignalingChange(PeerConnection.SignalingState signalingState) {
|
|
||||||
Log.e(TAG, "onSignalingChange: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
|
|
||||||
Log.e(TAG, "onIceGatheringChange: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {
|
|
||||||
Log.e(TAG, "onIceCandidatesRemoved: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onConnectionChange(PeerConnection.PeerConnectionState newState) {
|
|
||||||
Log.e(TAG, "onConnectionChange: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onIceConnectionReceivingChange(boolean b) {
|
|
||||||
Log.e(TAG, "onIceConnectionReceivingChange: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onRemoveStream(MediaStream mediaStream) {
|
|
||||||
Log.e(TAG, "onRemoveStream: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDataChannel(DataChannel dataChannel) {
|
|
||||||
Log.e(TAG, "onDataChannel: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onRenegotiationNeeded() {
|
|
||||||
Log.e(TAG, "onRenegotiationNeeded: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
|
|
||||||
Log.e(TAG, "onAddTrack: ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 核心信令交互方法(对外提供调用) ========================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 1. 收到远端发送的 SDP Offer 数据(信令协商第一步)
|
|
||||||
*
|
|
||||||
* @param sdpJson 远端发送的Offer-SDP字符串(JSON格式)
|
|
||||||
*/
|
|
||||||
public void onReceiveRemoteOffer(String sdpJson) {
|
|
||||||
SessionDescription offerSdp = new SessionDescription(SessionDescription.Type.OFFER, sdpJson);
|
|
||||||
peerConnection.setRemoteDescription(new SdpObserver() {
|
|
||||||
@Override
|
|
||||||
public void onCreateSuccess(SessionDescription sessionDescription) {
|
|
||||||
Log.e("setRemoteDescription", "onCreateSuccess: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSetSuccess() {
|
|
||||||
Log.e("setRemoteDescription", "onCreateSuccess: ");
|
|
||||||
// 设置远端Offer成功,生成本地Answer并发送给远端
|
|
||||||
peerConnection.createAnswer(new SdpObserver() {
|
|
||||||
@Override
|
|
||||||
public void onCreateSuccess(SessionDescription answerSdp) {
|
|
||||||
peerConnection.setLocalDescription(new SdpObserver() {
|
|
||||||
@Override
|
|
||||||
public void onCreateSuccess(SessionDescription sessionDescription) {
|
|
||||||
Log.e("setLocalDescription", "onCreateSuccess: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSetSuccess() {
|
|
||||||
Log.e("setLocalDescription", "onSetSuccess: ");
|
|
||||||
// TODO: 将本地Answer-SDP发送给远端
|
|
||||||
sendSdpAnswerToRemote(answerSdp);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateFailure(String s) {
|
|
||||||
Log.e("setLocalDescription", "onCreateFailure: " + s);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSetFailure(String s) {
|
|
||||||
Log.e("setLocalDescription", "onSetFailure: " + s);
|
|
||||||
}
|
|
||||||
}, answerSdp);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateFailure(String s) {
|
|
||||||
Log.e("createAnswer", "onCreateFailure: " + s);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSetSuccess() {
|
|
||||||
Log.e("createAnswer", "onSetSuccess: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSetFailure(String s) {
|
|
||||||
Log.e("createAnswer", "onSetFailure: " + s);
|
|
||||||
}
|
|
||||||
}, new MediaConstraints());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateFailure(String s) {
|
|
||||||
Log.e("setRemoteDescription", "onCreateFailure: " + s);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSetFailure(String s) {
|
|
||||||
Log.e("setRemoteDescription", "onSetFailure: " + s);
|
|
||||||
}
|
|
||||||
}, offerSdp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 2. 收到远端发送的 ICE候选地址
|
|
||||||
*
|
|
||||||
* @param iceCandidateJson 远端的ICE地址字符串(JSON格式)
|
|
||||||
* @param sdpMid ICE的mid标识
|
|
||||||
* @param sdpMLineIndex ICE的索引
|
|
||||||
*/
|
|
||||||
public void onReceiveRemoteIceCandidate(String iceCandidateJson, String sdpMid, int sdpMLineIndex) {
|
|
||||||
Log.e(TAG, "onReceiveRemoteIceCandidate: ");
|
|
||||||
|
|
||||||
IceCandidate iceCandidate = new IceCandidate(sdpMid, sdpMLineIndex, iceCandidateJson);
|
|
||||||
peerConnection.addIceCandidate(iceCandidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 信令发送的桥接方法(需要你对接自己的信令服务器) ========================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送本地ICE候选地址到远端
|
|
||||||
* TODO: 这里替换成你的信令逻辑(WebSocket/Http长连接/MQTT等)
|
|
||||||
*/
|
|
||||||
private void sendIceCandidateToRemote(IceCandidate iceCandidate) {
|
|
||||||
Log.e(TAG, "sendIceCandidateToRemote: ");
|
|
||||||
|
|
||||||
String mid = iceCandidate.sdpMid;
|
|
||||||
int index = iceCandidate.sdpMLineIndex;
|
|
||||||
String sdp = iceCandidate.sdp;
|
|
||||||
// 调用你的信令发送接口,把iceJson、mid、index发给远端
|
|
||||||
|
|
||||||
JsonObject jsonObject = new JsonObject();
|
|
||||||
jsonObject.addProperty("msg_type", 4);
|
|
||||||
|
|
||||||
// 发送信令消息
|
|
||||||
JsonObject iceCandidateInfo = new JsonObject();
|
|
||||||
iceCandidateInfo.addProperty("sdpMid", mid);
|
|
||||||
iceCandidateInfo.addProperty("sdpMLineIndex", index);
|
|
||||||
iceCandidateInfo.addProperty("sdp", sdp);
|
|
||||||
|
|
||||||
iceCandidateInfo.addProperty("fromUser", SystemUtils.getSerial());
|
|
||||||
iceCandidateInfo.addProperty("toUser", mRemoteUserId);
|
|
||||||
iceCandidateInfo.addProperty("timestamp", System.currentTimeMillis());
|
|
||||||
|
|
||||||
jsonObject.add("content", iceCandidateInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送本地Answer-SDP到远端
|
|
||||||
* TODO: 这里替换成你的信令逻辑
|
|
||||||
*/
|
|
||||||
private void sendSdpAnswerToRemote(SessionDescription description) {
|
|
||||||
Log.e(TAG, "sendIceCandidateToRemote: ");
|
|
||||||
|
|
||||||
// 调用你的信令发送接口,把sdpAnswer发给远端
|
|
||||||
JsonObject jsonObject = new JsonObject();
|
|
||||||
jsonObject.addProperty("msg_type", 3);
|
|
||||||
|
|
||||||
// 发送信令消息
|
|
||||||
JsonObject signal = new JsonObject();
|
|
||||||
signal.addProperty("type", description.type.canonicalForm()); // 示例:offer信令
|
|
||||||
signal.addProperty("sdp", description.description);
|
|
||||||
|
|
||||||
signal.addProperty("fromUser", SystemUtils.getSerial());
|
|
||||||
signal.addProperty("toUser", mRemoteUserId);
|
|
||||||
signal.addProperty("timestamp", System.currentTimeMillis());
|
|
||||||
|
|
||||||
jsonObject.add("content", signal);
|
|
||||||
|
|
||||||
signalingClient.sendSignal(jsonObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 生命周期管理 + 资源释放(必须,防止内存泄漏) ========================
|
|
||||||
@Override
|
|
||||||
protected void onDestroy() {
|
|
||||||
super.onDestroy();
|
|
||||||
// 释放渲染控件
|
|
||||||
if (mViewDataBinding.remoteVideoView != null) {
|
|
||||||
mViewDataBinding.remoteVideoView.release();
|
|
||||||
}
|
}
|
||||||
// 释放P2P连接
|
|
||||||
|
mViewDataBinding.remoteVideoView.init(rootEglBase.getEglBaseContext(), null);
|
||||||
|
mViewDataBinding.remoteVideoView.setMirror(false);
|
||||||
|
mViewDataBinding.remoteVideoView.setEnableHardwareScaler(true);
|
||||||
|
|
||||||
|
if (peerConnectionFactory == null) {
|
||||||
|
PeerConnectionFactory.InitializationOptions initOptions = PeerConnectionFactory.InitializationOptions
|
||||||
|
.builder(this)
|
||||||
|
.setEnableInternalTracer(true)
|
||||||
|
.createInitializationOptions();
|
||||||
|
PeerConnectionFactory.initialize(initOptions);
|
||||||
|
|
||||||
|
PeerConnectionFactory.Options factoryOptions = new PeerConnectionFactory.Options();
|
||||||
|
peerConnectionFactory = PeerConnectionFactory.builder()
|
||||||
|
.setOptions(factoryOptions)
|
||||||
|
.setVideoDecoderFactory(new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext()))
|
||||||
|
.setVideoEncoderFactory(new DefaultVideoEncoderFactory(rootEglBase.getEglBaseContext(), true, true))
|
||||||
|
.createPeerConnectionFactory();
|
||||||
|
}
|
||||||
|
|
||||||
|
mIsWebRTCReady = true;
|
||||||
|
mViewDataBinding.tvStatus.setText("WebRTC引擎就绪");
|
||||||
|
checkAndPerformConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createPeerConnection() {
|
||||||
|
Log.e(TAG, "createPeerConnection");
|
||||||
if (peerConnection != null) {
|
if (peerConnection != null) {
|
||||||
peerConnection.close();
|
peerConnection.close();
|
||||||
peerConnection.dispose();
|
peerConnection.dispose();
|
||||||
}
|
}
|
||||||
// 释放工厂
|
|
||||||
if (peerConnectionFactory != null) {
|
|
||||||
peerConnectionFactory.dispose();
|
|
||||||
}
|
|
||||||
// 释放EGL环境
|
|
||||||
if (rootEglBase != null) {
|
|
||||||
rootEglBase.release();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signalingClient != null) {
|
PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(ICE_SERVERS);
|
||||||
signalingClient.disconnect();
|
peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, new PeerConnection.Observer() {
|
||||||
}
|
@Override
|
||||||
|
public void onIceCandidate(IceCandidate iceCandidate) {
|
||||||
|
Log.e(TAG, "onIceCandidate");
|
||||||
|
sendIceCandidateToRemote(iceCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
|
||||||
|
Log.e(TAG, "onIceConnectionChange: " + iceConnectionState);
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
updateStatus("连接状态: " + iceConnectionState.name());
|
||||||
|
if (iceConnectionState == PeerConnection.IceConnectionState.CONNECTED) {
|
||||||
|
Log.e(TAG, "onIceConnectionChange: ✅ 已连接,正在接收远程屏幕" );
|
||||||
|
updateStatus("✅ 已连接,正在接收远程屏幕...");
|
||||||
|
} else if (iceConnectionState == PeerConnection.IceConnectionState.DISCONNECTED) {
|
||||||
|
updateStatus("❌ 连接已断开");
|
||||||
|
} else if (iceConnectionState == PeerConnection.IceConnectionState.FAILED) {
|
||||||
|
updateStatus("❌ 连接失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAddStream(MediaStream mediaStream) {
|
||||||
|
Log.e(TAG, "onAddStream");
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
Log.e(TAG, "onAddStream: ✅ 收到远程媒体流" );
|
||||||
|
updateStatus("✅ 收到远程媒体流");
|
||||||
|
if (!mediaStream.videoTracks.isEmpty()) {
|
||||||
|
VideoTrack videoTrack = mediaStream.videoTracks.get(0);
|
||||||
|
videoTrack.addSink(mViewDataBinding.remoteVideoView);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSignalingChange(PeerConnection.SignalingState signalingState) {
|
||||||
|
Log.e(TAG, "onSignalingChange: " + signalingState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
|
||||||
|
Log.e(TAG, "onIceGatheringChange: " + iceGatheringState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConnectionChange(PeerConnection.PeerConnectionState newState) {
|
||||||
|
Log.e(TAG, "onConnectionChange: " + newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceConnectionReceivingChange(boolean b) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRemoveStream(MediaStream mediaStream) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDataChannel(DataChannel dataChannel) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRenegotiationNeeded() {
|
||||||
|
Log.e(TAG, "onRenegotiationNeeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 信令交互 ========================
|
||||||
|
|
||||||
|
private void sendSignalingMessage(String type, SignalingMessage message) {
|
||||||
|
if (mWebSocketManager == null || !mWebSocketManager.isConnected()) return;
|
||||||
|
|
||||||
|
// 按照服务器要求的包装格式发送
|
||||||
|
JsonObject wrapper = new JsonObject();
|
||||||
|
wrapper.addProperty("type", type);
|
||||||
|
wrapper.addProperty("target", mRemoteUserId);
|
||||||
|
wrapper.add("data", mGson.toJsonTree(message));
|
||||||
|
|
||||||
|
mWebSocketManager.sendSignal(wrapper.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendInitToRemote() {
|
||||||
|
SignalingMessage message = new SignalingMessage("init", mWebSocketManager.getLocalUserId(), mRemoteUserId, "Ready to connect");
|
||||||
|
sendSignalingMessage("init", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendAskToRemote() {
|
||||||
|
SignalingMessage message = new SignalingMessage("ask", mWebSocketManager.getLocalUserId(), mRemoteUserId, "ask to " + mRemoteUserId);
|
||||||
|
sendSignalingMessage("ask", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendOfferToRemote() {
|
||||||
|
SignalingMessage message = new SignalingMessage("offer", mWebSocketManager.getLocalUserId(), mRemoteUserId, "offer to " + mRemoteUserId);
|
||||||
|
sendSignalingMessage("offer", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 实现 SignalingListener 接口方法
|
private void sendIceCandidateToRemote(IceCandidate iceCandidate) {
|
||||||
|
if (mWebSocketManager != null && mWebSocketManager.isConnected()) {
|
||||||
|
SignalingMessage message = new SignalingMessage("iceCandidate", mWebSocketManager.getLocalUserId(), mRemoteUserId, iceCandidate);
|
||||||
|
sendSignalingMessage("iceCandidate", message);
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Signaling not connected, queueing ice candidate");
|
||||||
|
mPendingIceCandidates.add(iceCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendIceCandidateToRemote() {
|
||||||
|
if (peerConnection == null) {
|
||||||
|
createPeerConnection();
|
||||||
|
}
|
||||||
|
SignalingMessage message = new SignalingMessage("iceCandidate", mWebSocketManager.getLocalUserId(), mRemoteUserId, "offer to remote");
|
||||||
|
sendSignalingMessage("iceCandidate", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendSdpAnswerToRemote(SessionDescription description) {
|
||||||
|
SignalingMessage message = new SignalingMessage("answer", mWebSocketManager.getLocalUserId(), mRemoteUserId, description);
|
||||||
|
sendSignalingMessage("answer", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onReceiveRemoteOffer(String jsonString) {
|
||||||
|
|
||||||
|
Log.e(TAG, "onReceiveRemoteOffer");
|
||||||
|
if (peerConnection == null) {
|
||||||
|
createPeerConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
SignalingMessage message = mGson.fromJson(jsonString, SignalingMessage.class);
|
||||||
|
SessionDescription offerSdp = mGson.fromJson(mGson.toJson(message.getPayload()), SessionDescription.class);
|
||||||
|
|
||||||
|
peerConnection.setRemoteDescription(new SimpleSdpObserver("setRemoteDescription") {
|
||||||
|
@Override
|
||||||
|
public void onSetSuccess() {
|
||||||
|
super.onSetSuccess();
|
||||||
|
peerConnection.createAnswer(new SimpleSdpObserver("createAnswer") {
|
||||||
|
@Override
|
||||||
|
public void onCreateSuccess(SessionDescription sessionDescription) {
|
||||||
|
Log.e(TAG, "onReceiveRemoteOffer onCreateSuccess: " );
|
||||||
|
super.onCreateSuccess(sessionDescription);
|
||||||
|
peerConnection.setLocalDescription(new SimpleSdpObserver("setLocalDescription") {
|
||||||
|
@Override
|
||||||
|
public void onSetSuccess() {
|
||||||
|
super.onSetSuccess();
|
||||||
|
sendSdpAnswerToRemote(sessionDescription);
|
||||||
|
}
|
||||||
|
}, sessionDescription);
|
||||||
|
}
|
||||||
|
}, new MediaConstraints());
|
||||||
|
}
|
||||||
|
}, offerSdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onReceiveRemoteAnswer(String sdp) {
|
||||||
|
Log.e(TAG, "onReceiveRemoteAnswer");
|
||||||
|
if (peerConnection != null) {
|
||||||
|
SessionDescription answerSdp = new SessionDescription(SessionDescription.Type.ANSWER, sdp);
|
||||||
|
peerConnection.setRemoteDescription(new SimpleSdpObserver("setRemoteAnswer"), answerSdp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onReceiveRemoteIceCandidate(String sdp, String sdpMid, int sdpMLineIndex) {
|
||||||
|
Log.e(TAG, "onReceiveRemoteIceCandidate");
|
||||||
|
if (peerConnection != null) {
|
||||||
|
IceCandidate iceCandidate = new IceCandidate(sdpMid, sdpMLineIndex, sdp);
|
||||||
|
peerConnection.addIceCandidate(iceCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== SignalingListener ========================
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onConnected(String sessionId) {
|
public void onConnected(String sessionId) {
|
||||||
Log.e(TAG, "onConnected: " + sessionId);
|
Log.e(TAG, "Signaling connected: " + sessionId);
|
||||||
|
|
||||||
// 调用你的信令发送接口,把sdpAnswer发给远端
|
|
||||||
JsonObject jsonObject = new JsonObject();
|
|
||||||
jsonObject.addProperty("msg_type", 3);
|
|
||||||
|
|
||||||
// 发送信令消息
|
|
||||||
JsonObject signal = new JsonObject();
|
|
||||||
signal.addProperty("type", "offer"); // 示例:offer信令
|
|
||||||
signal.addProperty("fromUser", SystemUtils.getSerial());
|
|
||||||
signal.addProperty("toUser", mRemoteUserId);
|
|
||||||
signal.addProperty("timestamp", System.currentTimeMillis());
|
|
||||||
|
|
||||||
jsonObject.add("content", signal);
|
|
||||||
|
|
||||||
signalingClient.sendSignal(jsonObject.toString());
|
|
||||||
|
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
updateStatus("已连接,会话ID: " + sessionId);
|
updateWsStatus("已连接到信令服务器");
|
||||||
addMessage("系统: 连接到信令服务器");
|
mViewDataBinding.btConnect.setEnabled(false);
|
||||||
|
|
||||||
|
// 发送暂存的 ICE Candidate
|
||||||
|
if (!mPendingIceCandidates.isEmpty()) {
|
||||||
|
Log.e(TAG, "Sending " + mPendingIceCandidates.size() + " pending ice candidates");
|
||||||
|
for (IceCandidate candidate : mPendingIceCandidates) {
|
||||||
|
sendIceCandidateToRemote(candidate);
|
||||||
|
}
|
||||||
|
mPendingIceCandidates.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAndPerformConnect();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private synchronized void checkAndPerformConnect() {
|
||||||
public void onMessageReceived(String msgType, String data) {
|
if (mIsWebRTCReady && mWebSocketManager != null && mWebSocketManager.isConnected() && !TextUtils.isEmpty(mRemoteUserId)) {
|
||||||
Log.e(TAG, "onMessageReceived: type = " + msgType);
|
Log.e(TAG, "Both WebRTC and Signaling are ready, sending init signal...");
|
||||||
Log.e(TAG, "onMessageReceived: data = " + data);
|
// 发送初始化消息告知对方我已上线并想连接
|
||||||
JsonObject jsonObject = GsonUtils.getJsonObject(data);
|
sendInitToRemote();
|
||||||
int msg_type = jsonObject.get("msg_type").getAsInt();
|
// sendIceCandidateToRemote(); // 现在改为手动点击 bt_stream 发送
|
||||||
if (msg_type == 11) {
|
runOnUiThread(() -> updateStatus("已向设备 " + mRemoteUserId + " 发送初始化消息..."));
|
||||||
String sdpJson = jsonObject.get("sdp").getAsString();
|
|
||||||
onReceiveRemoteOffer(sdpJson);
|
|
||||||
} else if (msg_type == 22) {
|
|
||||||
String iceJsonString = jsonObject.get("ice").getAsString();
|
|
||||||
JsonObject iceJson = GsonUtils.getJsonObject(iceJsonString);
|
|
||||||
String sdpMid = iceJson.get("sdpMid").getAsString();
|
|
||||||
int sdpMLineIndex = iceJson.get("sdpMLineIndex").getAsInt();
|
|
||||||
String sdp = iceJson.get("sdp").getAsString();
|
|
||||||
|
|
||||||
onReceiveRemoteIceCandidate(sdp, sdpMid, sdpMLineIndex);
|
|
||||||
}
|
}
|
||||||
runOnUiThread(() -> {
|
|
||||||
addMessage("收到[" + msgType + "]: " + data);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisconnected(String reason) {
|
public void onDisconnected(String reason) {
|
||||||
Log.e(TAG, "onDisconnected: ");
|
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
updateStatus("已断开: " + reason);
|
updateWsStatus("信令已断开: " + reason);
|
||||||
addMessage("系统: 连接断开 - " + reason);
|
mViewDataBinding.btConnect.setEnabled(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(String error) {
|
public void onMessage(String message) {
|
||||||
Log.e(TAG, "onError: " + error);
|
Log.e(TAG, "onMessageReceived: " + message);
|
||||||
|
try {
|
||||||
|
WebRTCMessage webRTCMessage = mGson.fromJson(message, WebRTCMessage.class);
|
||||||
|
String type = webRTCMessage.getType();
|
||||||
|
Object data = webRTCMessage.getData();
|
||||||
|
|
||||||
|
// 尝试解析为 SignalingMessage
|
||||||
|
SignalingMessage sm = null;
|
||||||
|
if (data != null) {
|
||||||
|
if (data instanceof String) {
|
||||||
|
try {
|
||||||
|
sm = mGson.fromJson((String) data, SignalingMessage.class);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
sm = mGson.fromJson(mGson.toJson(data), SignalingMessage.class);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "offer":
|
||||||
|
onReceiveRemoteOffer(sm != null ? mGson.toJson(sm) : (data instanceof String ? (String) data : mGson.toJson(data)));
|
||||||
|
break;
|
||||||
|
case "answer":
|
||||||
|
if (sm != null && sm.getPayload() != null) {
|
||||||
|
if (sm.getPayload() instanceof String) {
|
||||||
|
onReceiveRemoteAnswer((String) sm.getPayload());
|
||||||
|
} else {
|
||||||
|
JsonObject payloadJson = mGson.toJsonTree(sm.getPayload()).getAsJsonObject();
|
||||||
|
if (payloadJson.has("description")) {
|
||||||
|
onReceiveRemoteAnswer(payloadJson.get("description").getAsString());
|
||||||
|
} else {
|
||||||
|
onReceiveRemoteAnswer(mGson.toJson(sm.getPayload()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data instanceof String) {
|
||||||
|
onReceiveRemoteAnswer((String) data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "reject":
|
||||||
|
Object rejectReason = (sm != null && sm.getPayload() != null) ? sm.getPayload() : data;
|
||||||
|
handleRejectMessage(rejectReason instanceof String ? (String) rejectReason : mGson.toJson(rejectReason));
|
||||||
|
break;
|
||||||
|
case "accept":
|
||||||
|
Object acceptReason = (sm != null && sm.getPayload() != null) ? sm.getPayload() : data;
|
||||||
|
handleAcceptMessage(acceptReason instanceof String ? (String) acceptReason : mGson.toJson(acceptReason));
|
||||||
|
break;
|
||||||
|
case "sdp":
|
||||||
|
onReceiveRemoteAnswer((String) data);
|
||||||
|
break;
|
||||||
|
case "iceCandidate":
|
||||||
|
Object iceData = (sm != null && sm.getPayload() != null) ? sm.getPayload() : data;
|
||||||
|
try {
|
||||||
|
JsonObject iceJson = mGson.toJsonTree(iceData).getAsJsonObject();
|
||||||
|
String sdp = iceJson.get("sdp").getAsString();
|
||||||
|
String sdpMid = iceJson.get("sdpMid").getAsString();
|
||||||
|
int sdpMLineIndex = iceJson.get("sdpMLineIndex").getAsInt();
|
||||||
|
onReceiveRemoteIceCandidate(sdp, sdpMid, sdpMLineIndex);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error parsing iceCandidate: " + e.getMessage());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "init":
|
||||||
|
// 收到初始化请求,主动发起Offer (如果是这种逻辑)
|
||||||
|
// 在本客户端通常是被控端发Offer,如果本端想看远程,可能需要等对方发Offer
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error parsing signaling message", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleAcceptMessage(String reason) {
|
||||||
|
Log.e(TAG, "handleAcceptMessage: reason = " + reason);
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
Log.e(TAG, "WebSocket错误: " + error);
|
updateWsStatus("对方拒绝了连接: " + reason);
|
||||||
addMessage("错误: " + error);
|
Toast.makeText(this, "正在连接", Toast.LENGTH_SHORT).show();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRejectMessage(String reason) {
|
||||||
|
Log.e(TAG, "handleRejectMessage: reason = " + reason);
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
updateWsStatus("对方拒绝了连接: " + reason);
|
||||||
|
mViewDataBinding.btConnect.setEnabled(true);
|
||||||
|
Toast.makeText(this, "对方拒绝了连接," + reason, Toast.LENGTH_SHORT).show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(ByteString bytes) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String error) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
updateWsStatus("信令错误: " + error);
|
||||||
|
mViewDataBinding.btConnect.setEnabled(true);
|
||||||
|
Toast.makeText(this, "信令错误: " + error, Toast.LENGTH_SHORT).show();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateStatus(String status) {
|
private void updateStatus(String status) {
|
||||||
mViewDataBinding.tvStatus.setText("状态: " + status);
|
mViewDataBinding.tvStatus.setText(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addMessage(String message) {
|
private void updateWsStatus(String status) {
|
||||||
String current = mViewDataBinding.tvStatus.getText().toString();
|
mViewDataBinding.tvWsStatus.setText(status);
|
||||||
mViewDataBinding.tvStatus.setText(current + "\n" + message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================== 生命周期 ========================
|
||||||
|
|
||||||
public class BtnClick {
|
@Override
|
||||||
public void connectWebRtc(View view) {
|
protected void onDestroy() {
|
||||||
Editable editable = mViewDataBinding.etNumber.getText();
|
super.onDestroy();
|
||||||
if (TextUtils.isEmpty(editable)) {
|
if (mViewDataBinding.remoteVideoView != null) {
|
||||||
Log.e(TAG, "connectWebRtc: userId is empty");
|
mViewDataBinding.remoteVideoView.release();
|
||||||
mViewDataBinding.tvStatus.setText("用户id为空");
|
}
|
||||||
return;
|
if (peerConnection != null) {
|
||||||
}
|
peerConnection.close();
|
||||||
mRemoteUserId = editable.toString();
|
peerConnection.dispose();
|
||||||
// 创建信令客户端
|
}
|
||||||
signalingClient = new SignalingWebSocketClient(MainActivity.this);
|
if (peerConnectionFactory != null) {
|
||||||
signalingClient.connect();
|
peerConnectionFactory.dispose();
|
||||||
signalingClient.setUserId(mRemoteUserId);
|
}
|
||||||
updateStatus("正在连接...");
|
if (rootEglBase != null) {
|
||||||
|
rootEglBase.release();
|
||||||
|
}
|
||||||
|
if (mWebSocketManager != null) {
|
||||||
|
mWebSocketManager.removeCallback(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class BtnClick {
|
||||||
|
public void connectWebSocket(View view) {
|
||||||
|
if (mWebSocketManager != null) {
|
||||||
|
if (mWebSocketManager.isConnected()) {
|
||||||
|
updateWsStatus("已连接到信令服务器");
|
||||||
|
} else {
|
||||||
|
updateWsStatus("正在连接信令服务器...");
|
||||||
|
mWebSocketManager.connect();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mWebSocketManager = WebSocketManager.getInstance();
|
||||||
|
updateWsStatus("正在连接信令服务器...");
|
||||||
|
mWebSocketManager.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startStream(View view) {
|
||||||
|
Editable editable = mViewDataBinding.etNumber.getText();
|
||||||
|
if (TextUtils.isEmpty(editable)) {
|
||||||
|
Toast.makeText(MainActivity.this, "请输入设备ID", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mRemoteUserId = editable.toString();
|
||||||
|
if (mWebSocketManager == null || !mWebSocketManager.isConnected()) {
|
||||||
|
Toast.makeText(MainActivity.this, "请先连接WebSocket", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (TextUtils.isEmpty(mRemoteUserId)) {
|
||||||
|
Toast.makeText(MainActivity.this, "未指定远程设备ID", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.e(TAG, "Sending connection request to " + mRemoteUserId);
|
||||||
|
sendAskToRemote();
|
||||||
|
updateStatus("已发送连接请求给 " + mRemoteUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SdpObserver 简易适配器
|
||||||
|
private static class SimpleSdpObserver implements SdpObserver {
|
||||||
|
private final String tag;
|
||||||
|
|
||||||
|
SimpleSdpObserver(String tag) {
|
||||||
|
this.tag = tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreateSuccess(SessionDescription sessionDescription) {
|
||||||
|
Log.e(TAG, tag + " onCreateSuccess");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSetSuccess() {
|
||||||
|
Log.e(TAG, tag + " onSetSuccess");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreateFailure(String s) {
|
||||||
|
Log.e(TAG, tag + " onCreateFailure: " + s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSetFailure(String s) {
|
||||||
|
Log.e(TAG, tag + " onSetFailure: " + s);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.alibaba.android.arouter.launcher.ARouter;
|
|||||||
import com.tencent.bugly.crashreport.CrashReport;
|
import com.tencent.bugly.crashreport.CrashReport;
|
||||||
import com.tencent.mmkv.MMKV;
|
import com.tencent.mmkv.MMKV;
|
||||||
import com.ttstd.remoteclient.BuildConfig;
|
import com.ttstd.remoteclient.BuildConfig;
|
||||||
|
import com.ttstd.remoteclient.manager.WebSocketManager;
|
||||||
import com.ttstd.remoteclient.utils.SystemUtils;
|
import com.ttstd.remoteclient.utils.SystemUtils;
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +56,8 @@ public class BaseApplication extends Application {
|
|||||||
}
|
}
|
||||||
ARouter.init(this); // 尽可能早,推荐在Application中初始化
|
ARouter.init(this); // 尽可能早,推荐在Application中初始化
|
||||||
|
|
||||||
|
WebSocketManager.init(this);
|
||||||
|
|
||||||
CrashReport.initCrashReport(getApplicationContext(), "845e3ed68c", false);
|
CrashReport.initCrashReport(getApplicationContext(), "845e3ed68c", false);
|
||||||
CrashReport.setDeviceId(this, Build.MODEL);
|
CrashReport.setDeviceId(this, Build.MODEL);
|
||||||
xcrash.XCrash.init(this);
|
xcrash.XCrash.init(this);
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.ttstd.remoteclient.bean;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
|
|
||||||
public class SignalMessage implements Serializable {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ttstd.remoteclient.bean;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
public class SignalingMessage implements Serializable {
|
||||||
|
private String type; // "call", "offer", "answer", "iceCandidate", etc.
|
||||||
|
private String senderId; // 发送者ID
|
||||||
|
private String targetId; // 接收者ID
|
||||||
|
private Object payload; // 具体携带的数据(可以是String, SessionDescription, IceCandidate等)
|
||||||
|
|
||||||
|
public SignalingMessage(String type, String senderId, String targetId, Object payload) {
|
||||||
|
this.type = type;
|
||||||
|
this.senderId = senderId;
|
||||||
|
this.targetId = targetId;
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSenderId() {
|
||||||
|
return senderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSenderId(String senderId) {
|
||||||
|
this.senderId = senderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTargetId() {
|
||||||
|
return targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTargetId(String targetId) {
|
||||||
|
this.targetId = targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Object getPayload() {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPayload(Object payload) {
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.ttstd.remoteclient.bean;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
public class WebRTCMessage implements Serializable {
|
||||||
|
private String type; // "offer", "answer", "iceCandidate", "call", "hangup", "reject", "accept"
|
||||||
|
private String target;
|
||||||
|
private Object data;
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTarget() {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTarget(String target) {
|
||||||
|
this.target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Object getData() {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setData(Object data) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,4 +15,5 @@ public class WebSocketConfig {
|
|||||||
public static final long readTimeout = 10; // 读取超时时间(秒)
|
public static final long readTimeout = 10; // 读取超时时间(秒)
|
||||||
public static final long writeTimeout = 10; // 写入超时时间(秒)
|
public static final long writeTimeout = 10; // 写入超时时间(秒)
|
||||||
|
|
||||||
|
public static final long pingInterval = 10;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
package com.ttstd.remoteclient.manager;
|
|
||||||
|
|
||||||
import android.os.Handler;
|
|
||||||
import android.os.Looper;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.ttstd.remoteclient.utils.SystemUtils;
|
|
||||||
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import okhttp3.OkHttpClient;
|
|
||||||
import okhttp3.Request;
|
|
||||||
import okhttp3.Response;
|
|
||||||
import okhttp3.WebSocket;
|
|
||||||
import okhttp3.WebSocketListener;
|
|
||||||
import okio.ByteString;
|
|
||||||
|
|
||||||
public class SignalingWebSocketClient {
|
|
||||||
private static final String TAG = "SignalingWebSocket";
|
|
||||||
|
|
||||||
public interface SignalingListener {
|
|
||||||
void onConnected(String sessionId);
|
|
||||||
|
|
||||||
void onMessageReceived(String type, String data);
|
|
||||||
|
|
||||||
void onDisconnected(String reason);
|
|
||||||
|
|
||||||
void onError(String error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// private static final String SERVER_URL = "ws://175.178.213.60:2310/signaling/";
|
|
||||||
private static final String SERVER_URL = "ws://192.168.100.111:2310/signaling/";
|
|
||||||
|
|
||||||
private static final int RECONNECT_DELAY_MS = 3000;
|
|
||||||
|
|
||||||
private Gson gson = new Gson();
|
|
||||||
private OkHttpClient client;
|
|
||||||
private WebSocket webSocket;
|
|
||||||
private Request request;
|
|
||||||
|
|
||||||
private SignalingListener listener;
|
|
||||||
private Handler mainHandler;
|
|
||||||
private boolean mConnected = false;
|
|
||||||
private boolean shouldReconnect = true;
|
|
||||||
|
|
||||||
private String mUserId;
|
|
||||||
|
|
||||||
public SignalingWebSocketClient(SignalingListener listener) {
|
|
||||||
this.listener = listener;
|
|
||||||
this.mainHandler = new Handler(Looper.getMainLooper());
|
|
||||||
|
|
||||||
// 创建OkHttpClient,支持WebSocket
|
|
||||||
client = new OkHttpClient.Builder()
|
|
||||||
.pingInterval(20, TimeUnit.SECONDS) // 保持连接活跃
|
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUserId(String userId) {
|
|
||||||
mUserId = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void connect() {
|
|
||||||
if (mConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (webSocket != null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
request = new Request.Builder()
|
|
||||||
.url(SERVER_URL + SystemUtils.getSerial())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
mConnected = false;
|
|
||||||
shouldReconnect = true;
|
|
||||||
|
|
||||||
webSocket = client.newWebSocket(request, new WebSocketListener() {
|
|
||||||
@Override
|
|
||||||
public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {
|
|
||||||
mConnected = true;
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onConnected("连接成功");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) {
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
try {
|
|
||||||
JsonObject json = gson.fromJson(text, JsonObject.class);
|
|
||||||
String type = json.get("type").getAsString();
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "connected":
|
|
||||||
String sessionId = json.get("sessionId").getAsString();
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onConnected(sessionId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "signal":
|
|
||||||
String from = json.get("from").getAsString();
|
|
||||||
String data = json.get("data").toString();
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onMessageReceived("signal", "来自" + from + ": " + data);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "ack":
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onMessageReceived("ack", "消息发送成功");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "peer_left":
|
|
||||||
String leftSessionId = json.get("sessionId").getAsString();
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onMessageReceived("peer_left", "对端离开: " + leftSessionId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "error":
|
|
||||||
String errorMsg = json.get("message").getAsString();
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onError(errorMsg);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onMessageReceived("raw", text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) {
|
|
||||||
// 处理二进制消息
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onMessageReceived("binary", bytes.hex());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
|
|
||||||
mConnected = false;
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onDisconnected("连接关闭中: " + reason);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
|
|
||||||
mConnected = false;
|
|
||||||
webSocket = null;
|
|
||||||
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onDisconnected("连接已关闭: " + reason);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 自动重连
|
|
||||||
if (shouldReconnect) {
|
|
||||||
mainHandler.postDelayed(() -> connect(), RECONNECT_DELAY_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t,
|
|
||||||
@Nullable Response response) {
|
|
||||||
mConnected = false;
|
|
||||||
webSocket = null;
|
|
||||||
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onError("连接失败: " + t.getMessage());
|
|
||||||
listener.onDisconnected(t.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 自动重连
|
|
||||||
if (shouldReconnect) {
|
|
||||||
mainHandler.postDelayed(() -> connect(), RECONNECT_DELAY_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void sendSignal(String signalData) {
|
|
||||||
if (webSocket != null) {
|
|
||||||
webSocket.send(signalData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void sendJsonSignal(JsonObject signalJson) {
|
|
||||||
if (webSocket != null) {
|
|
||||||
webSocket.send(gson.toJson(signalJson));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void disconnect() {
|
|
||||||
mConnected = false;
|
|
||||||
shouldReconnect = false;
|
|
||||||
|
|
||||||
if (webSocket != null) {
|
|
||||||
webSocket.close(1000, "正常断开");
|
|
||||||
webSocket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (client != null) {
|
|
||||||
client.dispatcher().executorService().shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isConnected() {
|
|
||||||
return webSocket != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void runOnUiThread(Runnable runnable) {
|
|
||||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
||||||
runnable.run();
|
|
||||||
} else {
|
|
||||||
mainHandler.post(runnable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.ttstd.remoteclient.manager;
|
||||||
|
|
||||||
|
import okio.ByteString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket 回调接口
|
||||||
|
* 所有回调都在主线程执行,方便更新 UI
|
||||||
|
*/
|
||||||
|
public interface WebSocketCallback {
|
||||||
|
/**
|
||||||
|
* 连接成功
|
||||||
|
*/
|
||||||
|
void onConnected(String sessionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接断开
|
||||||
|
*
|
||||||
|
* @param reason 断开原因
|
||||||
|
*/
|
||||||
|
void onDisconnected(String reason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收到文本消息
|
||||||
|
*
|
||||||
|
* @param message 文本内容
|
||||||
|
*/
|
||||||
|
void onMessage(String message);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收到二进制消息
|
||||||
|
*
|
||||||
|
* @param bytes 二进制数据
|
||||||
|
*/
|
||||||
|
void onMessage(ByteString bytes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接发生错误
|
||||||
|
*
|
||||||
|
* @param error 错误信息
|
||||||
|
*/
|
||||||
|
void onError(String error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package com.ttstd.remoteclient.manager;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.ttstd.remoteclient.config.WebSocketConfig;
|
||||||
|
import com.ttstd.remoteclient.utils.SystemUtils;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import okhttp3.WebSocket;
|
||||||
|
import okhttp3.WebSocketListener;
|
||||||
|
import okio.ByteString;
|
||||||
|
|
||||||
|
public class WebSocketManager {
|
||||||
|
private static final String TAG = "WebSocketManager";
|
||||||
|
|
||||||
|
@SuppressLint("StaticFieldLeak")
|
||||||
|
private static volatile WebSocketManager instance;
|
||||||
|
|
||||||
|
private final Context mContext;
|
||||||
|
|
||||||
|
//private static final String SERVER_URL = "ws://175.178.213.60:2310/signaling/";
|
||||||
|
private static final String SERVER_URL = "ws://192.168.100.224:2310/signaling/";
|
||||||
|
|
||||||
|
private static final int RECONNECT_DELAY_MS = 3000;
|
||||||
|
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
private final OkHttpClient client;
|
||||||
|
private final Request request;
|
||||||
|
private WebSocket webSocket;
|
||||||
|
|
||||||
|
private final Set<WebSocketCallback> mCallbackSet = new CopyOnWriteArraySet<>();
|
||||||
|
private boolean mConnected = false;
|
||||||
|
private boolean shouldReconnect = true;
|
||||||
|
|
||||||
|
private final String mLocalUserId;
|
||||||
|
|
||||||
|
private WebSocketManager(Context context) {
|
||||||
|
this.mContext = context.getApplicationContext();
|
||||||
|
this.mLocalUserId = SystemUtils.getUniqueID(mContext);
|
||||||
|
|
||||||
|
// 创建OkHttpClient,支持WebSocket
|
||||||
|
client = new OkHttpClient.Builder()
|
||||||
|
.pingInterval(WebSocketConfig.pingInterval, TimeUnit.SECONDS) // 保持连接活跃
|
||||||
|
.connectTimeout(WebSocketConfig.connectTimeout, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(WebSocketConfig.readTimeout, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(WebSocketConfig.writeTimeout, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
request = new Request.Builder()
|
||||||
|
.url(SERVER_URL + mLocalUserId)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
webSocket = client.newWebSocket(request, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void init(Context context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (WebSocketManager.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new WebSocketManager(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WebSocketManager getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
throw new IllegalStateException("WebSocketManager must be initialized first with init(Context)");
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addCallback(WebSocketCallback listener) {
|
||||||
|
this.mCallbackSet.add(listener);
|
||||||
|
if (isConnected()) {
|
||||||
|
listener.onConnected("连接成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeCallback(WebSocketCallback listener) {
|
||||||
|
this.mCallbackSet.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void connect() {
|
||||||
|
if (mConnected && webSocket != null) return;
|
||||||
|
webSocket = client.newWebSocket(request, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final WebSocketListener listener = new WebSocketListener() {
|
||||||
|
@Override
|
||||||
|
public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {
|
||||||
|
Log.e(TAG, "onOpen: " + response.message());
|
||||||
|
mConnected = true;
|
||||||
|
for (WebSocketCallback callback : mCallbackSet) {
|
||||||
|
callback.onConnected("连接成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) {
|
||||||
|
Log.e(TAG, "onMessage: " + text);
|
||||||
|
for (WebSocketCallback callback : mCallbackSet) {
|
||||||
|
callback.onMessage(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) {
|
||||||
|
Log.d(TAG, "onMessage: Received binary message, size = " + bytes.size());
|
||||||
|
// 处理二进制消息
|
||||||
|
for (WebSocketCallback callback : mCallbackSet) {
|
||||||
|
callback.onMessage(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
|
||||||
|
Log.e(TAG, "onClosing: code = " + code + " reason = " + reason);
|
||||||
|
mConnected = false;
|
||||||
|
for (WebSocketCallback callback : mCallbackSet) {
|
||||||
|
callback.onDisconnected("连接关闭中: " + reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
|
||||||
|
Log.e(TAG, "onClosed: code = " + code + " reason = " + reason);
|
||||||
|
mConnected = false;
|
||||||
|
WebSocketManager.this.webSocket = null;
|
||||||
|
|
||||||
|
for (WebSocketCallback callback : mCallbackSet) {
|
||||||
|
callback.onDisconnected("连接已关闭: " + reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动重连
|
||||||
|
if (shouldReconnect) {
|
||||||
|
// 由于删除了 Handler,且用户要求不考虑线程,这里直接重连或在子线程重连
|
||||||
|
// OkHttp 的回调已经在子线程,这里直接调用 connect 会立即尝试。
|
||||||
|
// 加上延时通常更好,但用户说不考虑线程问题,这里可以用简单的方式。
|
||||||
|
try {
|
||||||
|
Thread.sleep(RECONNECT_DELAY_MS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) {
|
||||||
|
Log.e(TAG, "onFailure: Throwable = " + t.getMessage());
|
||||||
|
mConnected = false;
|
||||||
|
WebSocketManager.this.webSocket = null;
|
||||||
|
|
||||||
|
for (WebSocketCallback callback : mCallbackSet) {
|
||||||
|
callback.onError("检查网络");
|
||||||
|
callback.onDisconnected("连接失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动重连
|
||||||
|
if (shouldReconnect) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(RECONNECT_DELAY_MS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public void sendSignal(String signalData) {
|
||||||
|
if (webSocket != null && mConnected) {
|
||||||
|
webSocket.send(signalData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendJsonSignal(JsonObject signalJson) {
|
||||||
|
if (webSocket != null && mConnected) {
|
||||||
|
webSocket.send(gson.toJson(signalJson));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disconnect() {
|
||||||
|
mConnected = false;
|
||||||
|
shouldReconnect = false;
|
||||||
|
|
||||||
|
if (webSocket != null) {
|
||||||
|
webSocket.close(1000, "正常断开");
|
||||||
|
webSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConnected() {
|
||||||
|
return mConnected && webSocket != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocalUserId() {
|
||||||
|
return mLocalUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,38 +4,22 @@ import android.annotation.SuppressLint;
|
|||||||
import android.app.ActivityManager;
|
import android.app.ActivityManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.telephony.TelephonyManager;
|
||||||
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.FileReader;
|
||||||
|
import java.io.IOException;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public class SystemUtils {
|
public class SystemUtils {
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取设备序列号
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
public static String getSerial() {
|
|
||||||
String serial = "unknow";
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {//9.0+
|
|
||||||
serial = Build.getSerial();
|
|
||||||
} else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {//8.0+
|
|
||||||
serial = Build.SERIAL;
|
|
||||||
} else {//8.0-
|
|
||||||
Class<?> c = Class.forName("android.os.SystemProperties");
|
|
||||||
Method get = c.getMethod("get", String.class);
|
|
||||||
serial = (String) get.invoke(c, "ro.serialno");
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
Log.e("e", "读取设备序列号异常:" + e.toString());
|
|
||||||
}
|
|
||||||
return serial;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isMainProcessName(Context cxt, int pid) {
|
public static boolean isMainProcessName(Context cxt, int pid) {
|
||||||
String packageName = cxt.getPackageName();
|
String packageName = cxt.getPackageName();
|
||||||
ActivityManager am = (ActivityManager) cxt.getSystemService(Context.ACTIVITY_SERVICE);
|
ActivityManager am = (ActivityManager) cxt.getSystemService(Context.ACTIVITY_SERVICE);
|
||||||
@@ -50,4 +34,112 @@ public class SystemUtils {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final String CACHE_FILE_NAME = ".device_id_cache";
|
||||||
|
private static String sUniqueId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备唯一标识(核心兼容方法)
|
||||||
|
*/
|
||||||
|
public static String getUniqueID(Context context) {
|
||||||
|
if (!TextUtils.isEmpty(sUniqueId)) {
|
||||||
|
return sUniqueId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context appContext = context.getApplicationContext();
|
||||||
|
|
||||||
|
// 1. 尝试从本地缓存(内存/文件)读取,保证一致性
|
||||||
|
sUniqueId = readIdFromFile(appContext);
|
||||||
|
if (!TextUtils.isEmpty(sUniqueId)) {
|
||||||
|
return sUniqueId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 针对 Android 10 (API 29) 及以上版本的处理
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// 高版本直接使用 ANDROID_ID
|
||||||
|
sUniqueId = getAndroidId(appContext);
|
||||||
|
} else {
|
||||||
|
// 3. 针对 Android 10 以下版本的处理:尝试获取 IMEI
|
||||||
|
sUniqueId = getIMEI(appContext);
|
||||||
|
if (TextUtils.isEmpty(sUniqueId)) {
|
||||||
|
sUniqueId = getAndroidId(appContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 极端兜底:如果 ANDROID_ID 也为空(极少见)或格式错误,生成一个随机 UUID
|
||||||
|
if (TextUtils.isEmpty(sUniqueId) || "9774d56d6824155a".equals(sUniqueId)) {
|
||||||
|
sUniqueId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 将生成的唯一标识持久化到本地,防止应用卸载重装或系统升级后发生改变
|
||||||
|
saveIdToFile(appContext, sUniqueId);
|
||||||
|
|
||||||
|
return sUniqueId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 ANDROID_ID
|
||||||
|
* 优点:Android 10+ 不需要任何权限
|
||||||
|
* 缺点:设备恢复出厂设置会改变;不同签名的 App 获取到的值可能不同
|
||||||
|
*/
|
||||||
|
@SuppressLint("HardwareIds")
|
||||||
|
private static String getAndroidId(Context context) {
|
||||||
|
try {
|
||||||
|
String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
|
||||||
|
if (!TextUtils.isEmpty(androidId)) {
|
||||||
|
return androidId;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 IMEI (仅在 Android 10 以下有效,且需要 READ_PHONE_STATE 权限)
|
||||||
|
*/
|
||||||
|
@SuppressLint({"HardwareIds", "MissingPermission"})
|
||||||
|
private static String getIMEI(Context context) {
|
||||||
|
try {
|
||||||
|
TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
|
||||||
|
if (tm != null) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
return tm.getImei();
|
||||||
|
} else {
|
||||||
|
return tm.getDeviceId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 权限未授予或 Android 10+ 抛出异常,直接捕获
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从内部存储读取缓存的 ID
|
||||||
|
*/
|
||||||
|
private static String readIdFromFile(Context context) {
|
||||||
|
File file = new File(context.getFilesDir(), CACHE_FILE_NAME);
|
||||||
|
if (!file.exists()) return null;
|
||||||
|
|
||||||
|
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
|
||||||
|
return reader.readLine();
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 ID 保存到内部存储
|
||||||
|
*/
|
||||||
|
private static void saveIdToFile(Context context, String id) {
|
||||||
|
File file = new File(context.getFilesDir(), CACHE_FILE_NAME);
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(file)) {
|
||||||
|
fos.write(id.getBytes());
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/src/main/res/layout/activity_display.xml
Normal file
10
app/src/main/res/layout/activity_display.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/main"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".activity.display.DisplayActivity">
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -15,13 +15,36 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_ws_status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:text="等待连接..."
|
||||||
|
android:textColor="#000000"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/bt_connect"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:onClick="@{click::connectWebSocket}"
|
||||||
|
android:text="连接websocket"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tv_ws_status" />
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/constraintLayout"
|
android:id="@+id/constraintLayout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="80dp"
|
android:layout_height="80dp"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
app:layout_constraintTop_toBottomOf="@+id/bt_connect">
|
||||||
|
|
||||||
|
|
||||||
<EditText
|
<EditText
|
||||||
android:id="@+id/et_number"
|
android:id="@+id/et_number"
|
||||||
@@ -32,17 +55,17 @@
|
|||||||
android:hint="输入设备识别码"
|
android:hint="输入设备识别码"
|
||||||
android:text="981964879"
|
android:text="981964879"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toStartOf="@+id/button"
|
app:layout_constraintEnd_toStartOf="@+id/bt_stream"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/button"
|
android:id="@+id/bt_stream"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:onClick="@{click::connectWebRtc}"
|
android:onClick="@{click::startStream}"
|
||||||
android:text="连接"
|
android:text="开始远程"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|||||||
Reference in New Issue
Block a user