build: 更新项目至studio panda
This commit is contained in:
@@ -1,34 +1,27 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
compileSdkVersion 29
|
||||
namespace "com.ttstd.remoteclient"
|
||||
|
||||
compileSdkVersion 33
|
||||
// buildToolsVersion "36.0.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.ttstd.remoteclient"
|
||||
minSdkVersion 24
|
||||
targetSdkVersion 29
|
||||
targetSdkVersion 33
|
||||
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
ndk {
|
||||
//根据需要 自行选择添加的对应cpu类型的.so库。
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
// 还可以添加 'armeabi', 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64', 'mips', 'mips64'
|
||||
}
|
||||
|
||||
dataBinding {
|
||||
enabled true
|
||||
}
|
||||
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments = [AROUTER_MODULE_NAME: project.getName()]
|
||||
@@ -37,6 +30,16 @@ android {
|
||||
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
@@ -49,6 +52,14 @@ android {
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
|
||||
// 添加 Kotlin 标准库
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
// 添加这行,使用 BOM 统一 Kotlin 相关库的版本
|
||||
implementation(platform("org.jetbrains.kotlin:kotlin-bom:$kotlin_version"))
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.11.0'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
// For control over item selection of both touch and mouse driven selection
|
||||
@@ -101,7 +112,7 @@ dependencies {
|
||||
implementation 'com.jeremyliao:live-event-bus-x:1.7.3'
|
||||
|
||||
implementation 'org.webrtc:google-webrtc:1.0.32006'
|
||||
implementation 'org.java-websocket:Java-WebSocket:1.5.3'
|
||||
// implementation 'org.java-websocket:Java-WebSocket:1.5.3'
|
||||
|
||||
//MMKV
|
||||
implementation 'com.tencent:mmkv-static:1.2.14'
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".activity.main.MainActivity"
|
||||
android:theme="@style/AppThemeFitsSystem"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -34,14 +35,6 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- 屏幕采集服务 -->
|
||||
<service
|
||||
android:name=".service.ScreenCaptureService"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
|
||||
<!-- 指令执行服务 -->
|
||||
<service android:name=".service.ControlService" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,23 +1,64 @@
|
||||
package com.ttstd.remoteclient.activity.main;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.media.projection.MediaProjectionManager;
|
||||
import android.Manifest;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.ttstd.remoteclient.R;
|
||||
import com.ttstd.remoteclient.base.mvvm.BaseMvvmActivity;
|
||||
import com.ttstd.remoteclient.databinding.ActivityMainBinding;
|
||||
import com.ttstd.remoteclient.service.ControlService;
|
||||
import com.ttstd.remoteclient.service.ScreenCaptureService;
|
||||
import com.ttstd.remoteclient.gson.GsonUtils;
|
||||
import com.ttstd.remoteclient.manager.SignalingWebSocketClient;
|
||||
import com.ttstd.remoteclient.utils.SystemUtils;
|
||||
|
||||
public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBinding> {
|
||||
private static final String TAG ="MainActivity";
|
||||
import org.webrtc.DataChannel;
|
||||
import org.webrtc.DefaultVideoDecoderFactory;
|
||||
import org.webrtc.DefaultVideoEncoderFactory;
|
||||
import org.webrtc.EglBase;
|
||||
import org.webrtc.IceCandidate;
|
||||
import org.webrtc.MediaConstraints;
|
||||
import org.webrtc.MediaStream;
|
||||
import org.webrtc.PeerConnection;
|
||||
import org.webrtc.PeerConnectionFactory;
|
||||
import org.webrtc.RtpReceiver;
|
||||
import org.webrtc.SdpObserver;
|
||||
import org.webrtc.SessionDescription;
|
||||
import org.webrtc.VideoTrack;
|
||||
|
||||
private static final int REQUEST_CODE_SCREEN_CAPTURE = 7897;
|
||||
private MediaProjectionManager mMediaProjectionManager;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBinding> implements SignalingWebSocketClient.SignalingListener {
|
||||
private static final String TAG = "MainActivity";
|
||||
|
||||
private static final int NETWORK_REQUEST_CODE = 1001;
|
||||
|
||||
// ICE服务器配置(必须,用于NAT穿透,公共免费ICE服务器)
|
||||
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:175.178.213.60:3478").createIceServer());
|
||||
}};
|
||||
|
||||
// WebRTC 核心对象(核心引擎)
|
||||
private PeerConnectionFactory peerConnectionFactory;
|
||||
private PeerConnection peerConnection;
|
||||
private EglBase rootEglBase;
|
||||
|
||||
private SignalingWebSocketClient signalingClient;
|
||||
private String mRemoteUserId;
|
||||
|
||||
@Override
|
||||
public boolean setNightMode() {
|
||||
@@ -39,8 +80,8 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
|
||||
@Override
|
||||
protected void initView() {
|
||||
// 获取MediaProjection管理器
|
||||
mMediaProjectionManager = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||
// 申请权限
|
||||
requestPermissions();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -49,39 +90,411 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == REQUEST_CODE_SCREEN_CAPTURE && resultCode == RESULT_OK) {
|
||||
Log.d(TAG, "屏幕采集权限申请成功");
|
||||
// 启动屏幕采集服务
|
||||
Intent screenServiceIntent = new Intent(this, ScreenCaptureService.class);
|
||||
screenServiceIntent.putExtra("RESULT_CODE", resultCode);
|
||||
screenServiceIntent.putExtra("DATA", data);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(screenServiceIntent);
|
||||
}else {
|
||||
startService(screenServiceIntent);
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == NETWORK_REQUEST_CODE) {
|
||||
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
initWebRTC();
|
||||
} else {
|
||||
Toast.makeText(this, "权限被拒绝,无法使用屏幕共享功能", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动指令接收服务
|
||||
Intent controlServiceIntent = new Intent(this, ControlService.class);
|
||||
startService(controlServiceIntent);
|
||||
// 权限申请逻辑
|
||||
private void requestPermissions() {
|
||||
String[] permissions = {Manifest.permission.INTERNET, Manifest.permission.ACCESS_NETWORK_STATE};
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (ContextCompat.checkSelfPermission(this, permissions[0]) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this, permissions, NETWORK_REQUEST_CODE);
|
||||
} else {
|
||||
Log.e(TAG, "屏幕采集权限申请失败");
|
||||
initWebRTC();
|
||||
}
|
||||
} else {
|
||||
initWebRTC();
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== 核心:初始化WebRTC引擎 ========================
|
||||
private void initWebRTC() {
|
||||
Log.e(TAG, "initWebRTC: ");
|
||||
// 1. 初始化EGL环境(WebRTC渲染必须)
|
||||
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连接
|
||||
if (peerConnection != null) {
|
||||
peerConnection.close();
|
||||
peerConnection.dispose();
|
||||
}
|
||||
// 释放工厂
|
||||
if (peerConnectionFactory != null) {
|
||||
peerConnectionFactory.dispose();
|
||||
}
|
||||
// 释放EGL环境
|
||||
if (rootEglBase != null) {
|
||||
rootEglBase.release();
|
||||
}
|
||||
|
||||
if (signalingClient != null) {
|
||||
signalingClient.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 实现 SignalingListener 接口方法
|
||||
@Override
|
||||
public void onConnected(String sessionId) {
|
||||
Log.e(TAG, "onConnected: " + 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(() -> {
|
||||
updateStatus("已连接,会话ID: " + sessionId);
|
||||
addMessage("系统: 连接到信令服务器");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(String msgType, String data) {
|
||||
Log.e(TAG, "onMessageReceived: type = " + msgType);
|
||||
Log.e(TAG, "onMessageReceived: data = " + data);
|
||||
JsonObject jsonObject = GsonUtils.getJsonObject(data);
|
||||
int msg_type = jsonObject.get("msg_type").getAsInt();
|
||||
if (msg_type == 11) {
|
||||
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
|
||||
public void onDisconnected(String reason) {
|
||||
Log.e(TAG, "onDisconnected: ");
|
||||
runOnUiThread(() -> {
|
||||
updateStatus("已断开: " + reason);
|
||||
addMessage("系统: 连接断开 - " + reason);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String error) {
|
||||
Log.e(TAG, "onError: " + error);
|
||||
runOnUiThread(() -> {
|
||||
Log.e(TAG, "WebSocket错误: " + error);
|
||||
addMessage("错误: " + error);
|
||||
});
|
||||
}
|
||||
|
||||
private void updateStatus(String status) {
|
||||
mViewDataBinding.tvStatus.setText("状态: " + status);
|
||||
}
|
||||
|
||||
private void addMessage(String message) {
|
||||
String current = mViewDataBinding.tvStatus.getText().toString();
|
||||
mViewDataBinding.tvStatus.setText(current + "\n" + message);
|
||||
}
|
||||
|
||||
|
||||
public class BtnClick {
|
||||
public void requestPermission(View view){
|
||||
// 适配Android 13+ 通知权限
|
||||
// if (Build.VERSION.SDK_INT >= 33) {
|
||||
// if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
// ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1002);
|
||||
// }
|
||||
// }
|
||||
// 申请屏幕采集权限
|
||||
Intent captureIntent = mMediaProjectionManager.createScreenCaptureIntent();
|
||||
startActivityForResult(captureIntent, REQUEST_CODE_SCREEN_CAPTURE);
|
||||
public void connectWebRtc(View view) {
|
||||
Editable editable = mViewDataBinding.etNumber.getText();
|
||||
if (TextUtils.isEmpty(editable)) {
|
||||
Log.e(TAG, "connectWebRtc: userId is empty");
|
||||
mViewDataBinding.tvStatus.setText("用户id为空");
|
||||
return;
|
||||
}
|
||||
mRemoteUserId = editable.toString();
|
||||
// 创建信令客户端
|
||||
signalingClient = new SignalingWebSocketClient(MainActivity.this);
|
||||
signalingClient.connect();
|
||||
signalingClient.setUserId(mRemoteUserId);
|
||||
updateStatus("正在连接...");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ttstd.remoteclient.bean;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public class SignalMessage implements Serializable {
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ttstd.remoteclient.config;
|
||||
|
||||
/**
|
||||
* WebSocket 配置类
|
||||
* 用于配置WebSocket连接的各种参数
|
||||
*/
|
||||
public class WebSocketConfig {
|
||||
public String wsUrl; // WebSocket服务器地址
|
||||
public static final long heartbeatInterval = 30000; // 心跳间隔,默认10秒
|
||||
public static final long reconnectInterval = 5000; // 重连间隔,默认5秒
|
||||
public static final int maxReconnectAttempts = 5; // 最大重连次数
|
||||
public static final boolean autoReconnect = true; // 是否自动重连
|
||||
public static final boolean needHeartbeat = true; // 是否需要心跳检测
|
||||
public static final long connectTimeout = 10; // 连接超时时间(秒)
|
||||
public static final long readTimeout = 10; // 读取超时时间(秒)
|
||||
public static final long writeTimeout = 10; // 写入超时时间(秒)
|
||||
|
||||
}
|
||||
153
app/src/main/java/com/ttstd/remoteclient/gson/GsonUtils.java
Normal file
153
app/src/main/java/com/ttstd/remoteclient/gson/GsonUtils.java
Normal file
@@ -0,0 +1,153 @@
|
||||
package com.ttstd.remoteclient.gson;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
public class GsonUtils {
|
||||
//https://blog.csdn.net/zte1055889498/article/details/122400299
|
||||
|
||||
public static JsonObject getJsonObject(String jsonString) {
|
||||
JsonObject jsonObject = JsonParser.parseString(jsonString).getAsJsonObject();
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
private static final Gson gson;
|
||||
private static final Gson exposeGson;
|
||||
|
||||
static {
|
||||
GsonBuilder builder = new GsonBuilder();
|
||||
builder.registerTypeAdapterFactory(new NullStringToEmptyAdapterFactory());
|
||||
builder.registerTypeAdapter(Integer.class, new IntegerDefault0Adapter());
|
||||
builder.registerTypeAdapter(int.class, new IntegerDefault0Adapter());
|
||||
builder.disableHtmlEscaping();
|
||||
builder.enableComplexMapKeySerialization();
|
||||
// builder.excludeFieldsWithoutExposeAnnotation();
|
||||
builder.setDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
gson = builder.create();
|
||||
exposeGson = new GsonBuilder()
|
||||
.excludeFieldsWithoutExposeAnnotation()
|
||||
.create();
|
||||
}
|
||||
|
||||
public static Type makeJavaType(Type rawType, Type... typeArguments) {
|
||||
return TypeToken.getParameterized(rawType, typeArguments).getType();
|
||||
}
|
||||
|
||||
public static String toString(Object value) {
|
||||
if (Objects.isNull(value)) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return (String) value;
|
||||
}
|
||||
return toJSONString(value);
|
||||
}
|
||||
|
||||
public static String toJSONString(Object value) {
|
||||
return gson.toJson(value);
|
||||
}
|
||||
|
||||
public static String toExposeJSONString(Object value) {
|
||||
return gson.toJson(value);
|
||||
}
|
||||
|
||||
|
||||
public static String toPrettyString(Object value) {
|
||||
return gson.newBuilder().setPrettyPrinting().create().toJson(value);
|
||||
}
|
||||
|
||||
public static JsonElement fromJavaObject(Object value) {
|
||||
JsonElement result = null;
|
||||
if (Objects.nonNull(value) && (value instanceof String)) {
|
||||
result = parseObject((String) value);
|
||||
} else {
|
||||
result = gson.toJsonTree(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static JsonElement parseObject(String content) {
|
||||
return JsonParser.parseString(content);
|
||||
}
|
||||
|
||||
public static JsonElement getJsonElement(JsonObject node, String name) {
|
||||
return node.get(name);
|
||||
}
|
||||
|
||||
public static JsonElement getJsonElement(JsonArray node, int index) {
|
||||
return node.get(index);
|
||||
}
|
||||
|
||||
public static <T> T toJavaObject(JsonElement node, Class<T> clazz) {
|
||||
return gson.fromJson(node, clazz);
|
||||
}
|
||||
|
||||
public static <T> T toJavaObject(JsonElement node, Type type) {
|
||||
return gson.fromJson(node, type);
|
||||
}
|
||||
|
||||
public static <T> T toJavaObject(JsonElement node, TypeToken<?> typeToken) {
|
||||
return toJavaObject(node, typeToken.getType());
|
||||
}
|
||||
|
||||
public static <E> List<E> toJavaList(JsonElement node, Class<E> clazz) {
|
||||
return toJavaObject(node, makeJavaType(List.class, clazz));
|
||||
}
|
||||
|
||||
public static List<Object> toJavaList(JsonElement node) {
|
||||
return toJavaObject(node, new TypeToken<List<Object>>() {
|
||||
}.getType());
|
||||
}
|
||||
|
||||
public static <V> Map<String, V> toJavaMap(JsonElement node, Class<V> clazz) {
|
||||
return toJavaObject(node, makeJavaType(Map.class, String.class, clazz));
|
||||
}
|
||||
|
||||
public static Map<String, Object> toJavaMap(JsonElement node) {
|
||||
return toJavaObject(node, new TypeToken<Map<String, Object>>() {
|
||||
}.getType());
|
||||
}
|
||||
|
||||
public static <T> T toJavaObject(String content, Class<T> clazz) {
|
||||
JsonObject jsonObject = getJsonObject(content);
|
||||
String jsonString = jsonObject.toString();
|
||||
return gson.fromJson(jsonString, clazz);
|
||||
}
|
||||
|
||||
public static <T> T toJavaObject(String content, Type type) {
|
||||
return gson.fromJson(content, type);
|
||||
}
|
||||
|
||||
public static <T> T toJavaObject(String content, TypeToken<?> typeToken) {
|
||||
return toJavaObject(content, typeToken.getType());
|
||||
}
|
||||
|
||||
public static <E> List<E> toJavaList(String content, Class<E> clazz) {
|
||||
return toJavaObject(content, makeJavaType(List.class, clazz));
|
||||
}
|
||||
|
||||
public static List<Object> toJavaList(String content) {
|
||||
return toJavaObject(content, new TypeToken<List<Object>>() {
|
||||
}.getType());
|
||||
}
|
||||
|
||||
public static <V> Map<String, V> toJavaMap(String content, Class<V> clazz) {
|
||||
return toJavaObject(content, makeJavaType(Map.class, String.class, clazz));
|
||||
}
|
||||
|
||||
public static Map<String, Object> toJavaMap(String content) {
|
||||
return toJavaObject(content, new TypeToken<Map<String, Object>>() {
|
||||
}.getType());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ttstd.remoteclient.gson;
|
||||
|
||||
import com.google.gson.JsonDeserializationContext;
|
||||
import com.google.gson.JsonDeserializer;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.JsonSerializationContext;
|
||||
import com.google.gson.JsonSerializer;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
|
||||
public class IntegerDefault0Adapter implements JsonSerializer<Integer>, JsonDeserializer<Integer> {
|
||||
@Override
|
||||
public Integer deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
|
||||
throws JsonParseException {
|
||||
try {
|
||||
if (json.getAsString().equals("")) {
|
||||
return 0;
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
try {
|
||||
return json.getAsInt();
|
||||
} catch (NumberFormatException e) {
|
||||
throw new JsonSyntaxException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonElement serialize(Integer src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
return new JsonPrimitive(src);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ttstd.remoteclient.gson;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.TypeAdapterFactory;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class NullStringToEmptyAdapterFactory<T> implements TypeAdapterFactory {
|
||||
@Override
|
||||
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
|
||||
|
||||
Class<T> rawType = (Class<T>) type.getRawType();
|
||||
if (rawType != String.class) {
|
||||
return null;
|
||||
}
|
||||
return (TypeAdapter<T>) new StringAdapter();
|
||||
}
|
||||
|
||||
public static class StringAdapter extends TypeAdapter<String> {
|
||||
@Override
|
||||
public String read(JsonReader reader) throws IOException {
|
||||
if (reader.peek() == JsonToken.NULL) {
|
||||
reader.nextNull();
|
||||
return "";
|
||||
}
|
||||
return reader.nextString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(JsonWriter writer, String value) throws IOException {
|
||||
if (value == null) {
|
||||
writer.nullValue();
|
||||
return;
|
||||
}
|
||||
writer.value(value);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.ttstd.remoteclient.service;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class ControlService extends Service {
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
return super.onStartCommand(intent, flags, startId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLowMemory() {
|
||||
super.onLowMemory();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTrimMemory(int level) {
|
||||
super.onTrimMemory(level);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
package com.ttstd.remoteclient.service;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.ImageFormat;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.YuvImage;
|
||||
import android.hardware.display.VirtualDisplay;
|
||||
import android.media.Image;
|
||||
import android.media.ImageReader;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaFormat;
|
||||
import android.media.projection.MediaProjection;
|
||||
import android.media.projection.MediaProjectionManager;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.os.IBinder;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
public class ScreenCaptureService extends Service {
|
||||
private static final String TAG = "ScreenCaptureService";
|
||||
|
||||
private static final int NOTIFICATION_ID = 7897; // 前台服务通知ID
|
||||
private static final String CHANNEL_ID = "SCREEN_CAPTURE_CHANNEL"; // 通知渠道ID
|
||||
private static final String CHANNEL_NAME = "屏幕采集服务"; // 通知渠道名称
|
||||
|
||||
|
||||
// 编码参数配置
|
||||
private static final String MIME_TYPE = "video/avc"; // H.264编码
|
||||
|
||||
private static final int BIT_RATE = 2 * 1024 * 1024; // 码率2Mbps
|
||||
|
||||
private static final int FRAME_RATE = 15; // 帧率15fps
|
||||
|
||||
private static final int I_FRAME_INTERVAL = 5; // 关键帧间隔5秒
|
||||
|
||||
private static final String CONTROL_HOST = "192.168.1.100"; // 控制端IP(需替换为实际IP)
|
||||
private static final int CONTROL_PORT = 9999; // 控制端视频接收端口
|
||||
|
||||
private int mScreenWidth, mScreenHeight, mScreenDensity;
|
||||
|
||||
private MediaProjection mediaProjection;
|
||||
private MediaCodec mediaCodec;
|
||||
private Surface encodeSurface;
|
||||
private VirtualDisplay virtualDisplay;
|
||||
private Socket videoSocket;
|
||||
private OutputStream outputStream;
|
||||
|
||||
private boolean isEncoding = false;
|
||||
|
||||
// 采集参数
|
||||
private static final int IMAGE_FORMAT = ImageFormat.YUV_420_888; // 兼容大部分设备的格式
|
||||
|
||||
private ImageReader imageReader;
|
||||
private Handler captureHandler; // 帧回调处理线程
|
||||
private boolean isCapturing = false;
|
||||
private VirtualDisplay picVirtualDisplay;
|
||||
|
||||
// 用于存储提取的单帧Bitmap(可根据需求改为回调/文件存储)
|
||||
private Bitmap currentFrameBitmap;
|
||||
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
|
||||
DisplayMetrics metrics = new DisplayMetrics();
|
||||
windowManager.getDefaultDisplay().getRealMetrics(metrics); // 获取真实屏幕尺寸(包含状态栏等)
|
||||
mScreenWidth = metrics.widthPixels;
|
||||
mScreenHeight = metrics.heightPixels;
|
||||
mScreenDensity = metrics.densityDpi;
|
||||
Log.e(TAG, "onCreate: mScreenWidth = " + mScreenWidth);
|
||||
Log.e(TAG, "onCreate: mScreenHeight = " + mScreenHeight);
|
||||
Log.e(TAG, "onCreate: mScreenDensity = " + mScreenDensity);
|
||||
|
||||
Log.e(TAG, "onCreate: densityDpi = " + getResources().getDisplayMetrics().densityDpi);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
createForegroundNotification();
|
||||
|
||||
if (intent != null) {
|
||||
int resultCode = intent.getIntExtra("RESULT_CODE", -1);
|
||||
Intent data = intent.getParcelableExtra("DATA");
|
||||
|
||||
// 获取MediaProjection实例(屏幕采集核心)
|
||||
MediaProjectionManager mediaProjectionManager = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||
mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data);
|
||||
|
||||
if (mediaProjection != null) {
|
||||
Log.d(TAG, "MediaProjection初始化成功,开始屏幕采集");
|
||||
// 此处可扩展:添加VirtualDisplay + MediaCodec编码屏幕画面为H.264
|
||||
// 并通过Socket将编码后的视频流发送到控制端
|
||||
// 初始化编码器并开始采集
|
||||
// new Thread(this::initEncoderAndCapture).start();
|
||||
|
||||
// 初始化ImageReader并开始采集
|
||||
initImageReaderAndCapture();
|
||||
|
||||
} else {
|
||||
Log.e(TAG, "MediaProjection初始化失败");
|
||||
stopSelf();
|
||||
}
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化MediaCodec编码器,并创建虚拟显示采集屏幕
|
||||
*/
|
||||
private void initEncoderAndCapture() {
|
||||
try {
|
||||
// 1. 初始化MediaCodec编码器
|
||||
MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, mScreenWidth, mScreenHeight);
|
||||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);
|
||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
|
||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL);
|
||||
format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline);
|
||||
format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31);
|
||||
|
||||
mediaCodec = MediaCodec.createEncoderByType(MIME_TYPE);
|
||||
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
||||
encodeSurface = mediaCodec.createInputSurface(); // 获取编码输入Surface
|
||||
mediaCodec.start();
|
||||
isEncoding = true;
|
||||
Log.d(TAG, "MediaCodec编码器初始化成功");
|
||||
|
||||
// 1. 创建ImageReader,用于读取屏幕帧
|
||||
imageReader = ImageReader.newInstance(
|
||||
mScreenWidth,
|
||||
mScreenHeight,
|
||||
IMAGE_FORMAT,
|
||||
2 // 缓冲区数量:2帧(避免帧丢失)
|
||||
);
|
||||
|
||||
// 2. 创建虚拟显示,将屏幕画面投射到编码Surface
|
||||
virtualDisplay = mediaProjection.createVirtualDisplay(
|
||||
"ScreenCaptureDisplay",
|
||||
mScreenWidth,
|
||||
mScreenHeight,
|
||||
mScreenDensity,
|
||||
android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC,
|
||||
encodeSurface,
|
||||
null, // 显示回调(可选)
|
||||
null // 回调处理线程(可选)
|
||||
);
|
||||
Log.d(TAG, "虚拟显示创建成功,开始屏幕采集");
|
||||
|
||||
// 3. 连接控制端并发送编码数据
|
||||
connectToControlServer();
|
||||
// 4. 循环读取编码后的数据并发送
|
||||
readAndSendEncodedData();
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "编码器初始化失败", e);
|
||||
stopEncoding();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接控制端视频接收服务
|
||||
*/
|
||||
private void connectToControlServer() {
|
||||
try {
|
||||
videoSocket = new Socket(CONTROL_HOST, CONTROL_PORT);
|
||||
outputStream = videoSocket.getOutputStream();
|
||||
Log.d(TAG, "已连接到控制端:" + CONTROL_HOST + ":" + CONTROL_PORT);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "连接控制端失败", e);
|
||||
stopEncoding();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 循环读取编码后的H.264数据,并发送到控制端
|
||||
*/
|
||||
private void readAndSendEncodedData() {
|
||||
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
|
||||
while (isEncoding) {
|
||||
// 获取编码后的数据包索引
|
||||
int outputBufferId = mediaCodec.dequeueOutputBuffer(bufferInfo, 10000); // 10秒超时
|
||||
if (outputBufferId == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||
// 暂无数据,继续等待
|
||||
continue;
|
||||
} else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
// 格式变更(如SPS/PPS数据),获取并发送
|
||||
MediaFormat newFormat = mediaCodec.getOutputFormat();
|
||||
Log.d(TAG, "编码器格式变更:" + newFormat);
|
||||
sendSpsPps(newFormat);
|
||||
} else if (outputBufferId >= 0) {
|
||||
// 处理编码后的视频数据
|
||||
ByteBuffer outputBuffer = mediaCodec.getOutputBuffer(outputBufferId);
|
||||
if (outputBuffer != null && bufferInfo.size > 0) {
|
||||
// 封装NALU(添加00 00 00 01起始码)
|
||||
byte[] naluData = new byte[bufferInfo.size + 4];
|
||||
naluData[0] = 0;
|
||||
naluData[1] = 0;
|
||||
naluData[2] = 0;
|
||||
naluData[3] = 1;
|
||||
outputBuffer.get(naluData, 4, bufferInfo.size);
|
||||
outputBuffer.clear();
|
||||
|
||||
// 发送数据到控制端
|
||||
if (outputStream != null) {
|
||||
try {
|
||||
outputStream.write(naluData);
|
||||
outputStream.flush();
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "发送视频数据失败", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 释放缓冲区
|
||||
mediaCodec.releaseOutputBuffer(outputBufferId, false);
|
||||
|
||||
// 检查是否结束
|
||||
if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
||||
Log.d(TAG, "编码结束");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送SPS/PPS数据(H.264解码必需的配置数据)
|
||||
*/
|
||||
private void sendSpsPps(MediaFormat format) {
|
||||
try {
|
||||
byte[] sps = format.getByteBuffer("csd-0").array();
|
||||
byte[] pps = format.getByteBuffer("csd-1").array();
|
||||
|
||||
// 封装并发送SPS
|
||||
byte[] spsData = new byte[sps.length + 4];
|
||||
spsData[0] = 0;
|
||||
spsData[1] = 0;
|
||||
spsData[2] = 0;
|
||||
spsData[3] = 1;
|
||||
System.arraycopy(sps, 0, spsData, 4, sps.length);
|
||||
outputStream.write(spsData);
|
||||
|
||||
// 封装并发送PPS
|
||||
byte[] ppsData = new byte[pps.length + 4];
|
||||
ppsData[0] = 0;
|
||||
ppsData[1] = 0;
|
||||
ppsData[2] = 0;
|
||||
ppsData[3] = 1;
|
||||
System.arraycopy(pps, 0, ppsData, 4, pps.length);
|
||||
outputStream.write(ppsData);
|
||||
|
||||
Log.d(TAG, "已发送SPS/PPS配置数据");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "发送SPS/PPS失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化ImageReader,创建虚拟显示采集屏幕帧
|
||||
*/
|
||||
private void initImageReaderAndCapture() {
|
||||
// 1. 创建ImageReader,用于读取屏幕帧
|
||||
imageReader = ImageReader.newInstance(
|
||||
mScreenWidth,
|
||||
mScreenHeight,
|
||||
PixelFormat.RGBA_8888,
|
||||
2 // 缓冲区数量:2帧(避免帧丢失)
|
||||
);
|
||||
|
||||
// 2. 创建帧回调线程(避免阻塞主线程)
|
||||
HandlerThread handlerThread = new HandlerThread("ScreenCaptureThread");
|
||||
handlerThread.start();
|
||||
captureHandler = new Handler(handlerThread.getLooper());
|
||||
|
||||
// 3. 设置帧可用回调
|
||||
imageReader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() {
|
||||
@Override
|
||||
public void onImageAvailable(ImageReader reader) {
|
||||
Image image = reader.acquireLatestImage();
|
||||
if (image != null) {
|
||||
Bitmap bitmap = imageToBitmap2(image); // 将Image转换为Bitmap
|
||||
// saveBitmapToFile(bitmap); // 保存Bitmap
|
||||
image.close(); // 重要:关闭Image释放资源
|
||||
}
|
||||
}
|
||||
}, captureHandler);
|
||||
isCapturing = true;
|
||||
|
||||
// 4. 创建虚拟显示,将屏幕画面投射到ImageReader的Surface
|
||||
picVirtualDisplay = mediaProjection.createVirtualDisplay(
|
||||
"ScreenCaptureDisplay",
|
||||
mScreenWidth,
|
||||
mScreenHeight,
|
||||
mScreenDensity,
|
||||
android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC,
|
||||
imageReader.getSurface(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
Log.d(TAG, "虚拟显示创建成功,开始屏幕帧采集");
|
||||
}
|
||||
|
||||
/**
|
||||
* 帧可用回调:每次屏幕刷新都会触发此方法
|
||||
*/
|
||||
private void onImageAvailable(ImageReader reader) {
|
||||
// 获取最新帧(注意:必须close(),否则缓冲区会耗尽)
|
||||
try (Image image = reader.acquireLatestImage()) {
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取当前帧并转换为Bitmap
|
||||
currentFrameBitmap = imageToBitmap(image);
|
||||
if (currentFrameBitmap != null) {
|
||||
Log.d(TAG, "成功提取一帧,Bitmap尺寸:" + currentFrameBitmap.getWidth() + "x" + currentFrameBitmap.getHeight());
|
||||
|
||||
// ========== 此处可添加Bitmap的使用逻辑 ==========
|
||||
// 示例1:保存为文件
|
||||
// saveBitmapToFile(currentFrameBitmap, "/sdcard/screen_shot.png");
|
||||
// 示例2:通过网络发送
|
||||
// sendBitmapToServer(currentFrameBitmap);
|
||||
// 示例3:仅提取一帧后停止采集
|
||||
// stopCapture();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "提取帧失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Image(YUV_420_888格式)转换为Bitmap
|
||||
*/
|
||||
private Bitmap imageToBitmap(Image image) {
|
||||
try {
|
||||
// 获取Image的YUV数据
|
||||
Image.Plane[] planes = image.getPlanes();
|
||||
ByteBuffer yBuffer = planes[0].getBuffer();
|
||||
ByteBuffer uBuffer = planes[1].getBuffer();
|
||||
ByteBuffer vBuffer = planes[2].getBuffer();
|
||||
|
||||
int ySize = yBuffer.remaining();
|
||||
int uSize = uBuffer.remaining();
|
||||
int vSize = vBuffer.remaining();
|
||||
|
||||
// 将YUV数据合并为字节数组
|
||||
byte[] yuvData = new byte[ySize + uSize + vSize];
|
||||
yBuffer.get(yuvData, 0, ySize);
|
||||
vBuffer.get(yuvData, ySize, vSize); // 注意:部分设备UV顺序是VU
|
||||
uBuffer.get(yuvData, ySize + vSize, uSize);
|
||||
|
||||
// YUV转JPEG,再转Bitmap(兼容所有设备)
|
||||
YuvImage yuvImage = new YuvImage(
|
||||
yuvData,
|
||||
ImageFormat.NV21, // 转换为NV21格式
|
||||
image.getWidth(),
|
||||
image.getHeight(),
|
||||
new int[]{planes[0].getRowStride(), 0, 0} // 行步长
|
||||
);
|
||||
|
||||
// 压缩为JPEG字节数组
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
yuvImage.compressToJpeg(new Rect(0, 0, image.getWidth(), image.getHeight()), 100, outputStream);
|
||||
byte[] jpegData = outputStream.toByteArray();
|
||||
|
||||
// JPEG字节数组转Bitmap
|
||||
Bitmap bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.length);
|
||||
|
||||
// 修正旋转(部分设备采集的帧会旋转90度)
|
||||
Matrix matrix = new Matrix();
|
||||
matrix.postRotate(90); // 根据实际情况调整旋转角度(0/90/180/270)
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "YUV转Bitmap失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap imageToBitmap2(Image image) {
|
||||
Image.Plane[] planes = image.getPlanes();
|
||||
ByteBuffer buffer = planes[0].getBuffer();
|
||||
int pixelStride = planes[0].getPixelStride();
|
||||
int rowStride = planes[0].getRowStride();
|
||||
int rowPadding = rowStride - pixelStride * image.getWidth(); // 计算行填充
|
||||
|
||||
// 创建Bitmap,注意处理行跨度可能大于图像宽度的情况
|
||||
Bitmap bitmap = Bitmap.createBitmap(
|
||||
image.getWidth() + rowPadding / pixelStride,
|
||||
image.getHeight(),
|
||||
Bitmap.Config.ARGB_8888
|
||||
);
|
||||
bitmap.copyPixelsFromBuffer(buffer);
|
||||
|
||||
// 如果存在填充,则裁剪出原始图像区域
|
||||
if (rowPadding > 0) {
|
||||
bitmap = Bitmap.createBitmap(bitmap, 0, 0, image.getWidth(), image.getHeight());
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
// private void saveBitmapToFile(Bitmap bitmap) {
|
||||
// // 生成唯一的文件名
|
||||
// String fileName = "Screenshot_" + System.currentTimeMillis() + ".png";
|
||||
// // 保存到公共图片目录
|
||||
// File saveFile = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), fileName);
|
||||
//
|
||||
// try (FileOutputStream out = new FileOutputStream(saveFile)) {
|
||||
// bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); // PNG无损压缩
|
||||
// Toast.makeText(this, "截图已保存: " + saveFile.getAbsolutePath(), Toast.LENGTH_SHORT).show();
|
||||
// } catch (IOException e) {
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
/**
|
||||
* 停止编码和采集
|
||||
*/
|
||||
private void stopEncoding() {
|
||||
isEncoding = false;
|
||||
|
||||
// 停止编码器
|
||||
if (mediaCodec != null) {
|
||||
try {
|
||||
mediaCodec.stop();
|
||||
mediaCodec.release();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "停止编码器失败", e);
|
||||
}
|
||||
mediaCodec = null;
|
||||
}
|
||||
|
||||
// 释放虚拟显示
|
||||
if (virtualDisplay != null) {
|
||||
virtualDisplay.release();
|
||||
virtualDisplay = null;
|
||||
}
|
||||
|
||||
if (picVirtualDisplay != null) {
|
||||
picVirtualDisplay.release();
|
||||
picVirtualDisplay = null;
|
||||
}
|
||||
|
||||
// 释放MediaProjection
|
||||
if (mediaProjection != null) {
|
||||
mediaProjection.stop();
|
||||
mediaProjection = null;
|
||||
}
|
||||
|
||||
// 关闭网络连接
|
||||
try {
|
||||
if (outputStream != null) outputStream.close();
|
||||
if (videoSocket != null) videoSocket.close();
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "关闭网络连接失败", e);
|
||||
}
|
||||
|
||||
Log.d(TAG, "屏幕采集已停止");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建前台服务通知,并将Service设为前台服务(核心修复点)
|
||||
*/
|
||||
private void createForegroundNotification() {
|
||||
// 第一步:创建通知渠道(Android O及以上必需)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW // 低优先级,避免打扰用户
|
||||
);
|
||||
NotificationManager notificationManager = getSystemService(NotificationManager.class);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
// 第二步:构建基础通知
|
||||
Notification.Builder builder = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? new Notification.Builder(this, CHANNEL_ID)
|
||||
: new Notification.Builder(this);
|
||||
|
||||
Notification notification = builder
|
||||
.setContentTitle("远程控制服务")
|
||||
.setContentText("屏幕采集服务正在运行")
|
||||
.setSmallIcon(android.R.drawable.ic_media_play) // 替换为你的应用图标
|
||||
.setOngoing(true) // 设为不可取消(前台服务特性)
|
||||
.build();
|
||||
|
||||
// 第三步:启动前台服务(关键:指定MediaProjection类型)
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
// Android 12+ 必须指定前台服务类型
|
||||
startForeground(NOTIFICATION_ID, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
|
||||
} else {
|
||||
// 低版本直接启动
|
||||
startForeground(NOTIFICATION_ID, notification);
|
||||
}
|
||||
Log.d(TAG, "前台服务启动成功");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
if (mediaProjection != null) {
|
||||
mediaProjection.stop();
|
||||
Log.d(TAG, "屏幕采集服务停止");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLowMemory() {
|
||||
super.onLowMemory();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTrimMemory(int level) {
|
||||
super.onTrimMemory(level);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,41 @@
|
||||
package com.ttstd.remoteclient.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.ActivityManager;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
|
||||
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) {
|
||||
String packageName = cxt.getPackageName();
|
||||
ActivityManager am = (ActivityManager) cxt.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
|
||||
@@ -15,17 +15,62 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/constraintLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_number"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:hint="输入设备识别码"
|
||||
android:text="981964879"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/button"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/bt_premission"
|
||||
android:id="@+id/button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:onClick="@{click::requestPermission}"
|
||||
android:text="权限"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:onClick="@{click::connectWebRtc}"
|
||||
android:text="连接"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center"
|
||||
android:maxLines="2"
|
||||
android:text="等待连接..."
|
||||
android:textColor="#000000"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/constraintLayout" />
|
||||
|
||||
<org.webrtc.SurfaceViewRenderer
|
||||
android:id="@+id/remote_video_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tv_status" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</layout>
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">TTSTDRemoteService</string>
|
||||
<string name="app_name">TTSTDRemoteClient</string>
|
||||
</resources>
|
||||
|
||||
@@ -15,4 +15,12 @@
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppThemeFitsSystem" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="android:fitsSystemWindows">true</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '2.3.21'
|
||||
|
||||
repositories {
|
||||
google()
|
||||
@@ -13,8 +14,9 @@ buildscript {
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.6.4'
|
||||
classpath 'com.android.tools.build:gradle:8.13.2'
|
||||
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
|
||||
Reference in New Issue
Block a user