Files
VibeCoding/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java

840 lines
35 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.ttstd.controller;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Spinner;
import android.widget.RadioGroup;
import androidx.appcompat.app.AlertDialog;
import java.util.ArrayList;
import java.util.List;
import androidx.appcompat.app.AppCompatActivity;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.ttstd.control.Action;
import com.ttstd.control.ControlMessage;
import com.ttstd.controller.signaling.SignalMessage;
import com.ttstd.controller.signaling.WebSocketClient;
import com.ttstd.controller.view.RemoteTouchView;
import com.ttstd.controller.utils.DeviceUtils;
import com.ttstd.controller.webrtc.WebRtcClient;
import org.webrtc.EglBase;
import org.webrtc.IceCandidate;
import org.webrtc.RendererCommon;
import org.webrtc.SurfaceViewRenderer;
import java.util.Locale;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "ControllerMain";
private EditText etServerUrl;
private EditText etDeviceId;
private EditText etTargetDeviceId;
private Button btnConnect;
private Button btnDisconnect;
private TextView tvStatus;
private SurfaceViewRenderer remoteVideoView;
private RemoteTouchView touchOverlay;
private FrameLayout controlPanel;
private LinearLayout setupPanel;
private TextView tvStatusControl;
private TextView tvStats;
private Spinner spinnerResolution;
private WebSocketClient wsClient;
private WebRtcClient webRtcClient;
private EglBase eglBase;
private final Gson gson = new Gson();
private String myDeviceId;
private String targetDeviceId;
private String pendingAuthType;
private String pendingAuthValue;
private final Handler statsHandler = new Handler(Looper.getMainLooper());
private final Runnable statsRunnable = new Runnable() {
@Override
public void run() {
updateStats();
statsHandler.postDelayed(this, 1000);
}
};
// 网速统计基线(累计字节数与上次采样时间戳)
private long prevBytesReceived = 0;
private long prevBytesSent = 0;
private long prevStatsTime = 0;
// 连接建立时的时间戳(毫秒),用于计算连接时长。
private long connectionStartTime = 0;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
etServerUrl = findViewById(R.id.et_server_url);
etDeviceId = findViewById(R.id.et_device_id);
etTargetDeviceId = findViewById(R.id.et_target_device_id);
btnConnect = findViewById(R.id.btn_connect);
btnDisconnect = findViewById(R.id.btn_disconnect);
tvStatus = findViewById(R.id.tv_status);
remoteVideoView = findViewById(R.id.remote_video_view);
touchOverlay = findViewById(R.id.touch_overlay);
controlPanel = findViewById(R.id.control_panel);
setupPanel = findViewById(R.id.setup_panel);
tvStatusControl = findViewById(R.id.tv_status_control);
tvStats = findViewById(R.id.tv_stats);
spinnerResolution = findViewById(R.id.spinner_resolution);
// 默认服务器地址
etServerUrl.setText("ws://175.178.213.60:8088/ws/signal");
// 生成设备ID非系统签名使用兜底方案获取设备标识
myDeviceId = DeviceUtils.getSerialNumber(this);
etDeviceId.setText(myDeviceId);
btnConnect.setOnClickListener(v -> showAuthDialog());
btnDisconnect.setOnClickListener(v -> disconnect());
// 初始化 EGL 和远端视频渲染
eglBase = EglBase.create();
remoteVideoView.init(eglBase.getEglBaseContext(), new RendererCommon.RendererEvents() {
@Override
public void onFirstFrameRendered() {
Log.d(TAG, "First frame rendered");
}
@Override
public void onFrameResolutionChanged(int videoWidth, int videoHeight, int rotation) {
Log.d(TAG, "Video resolution changed: " + videoWidth + "x" + videoHeight + " rotation: " + rotation);
adjustVideoSize(videoWidth, videoHeight, rotation);
}
});
remoteVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
remoteVideoView.setEnableHardwareScaler(true);
remoteVideoView.setZOrderMediaOverlay(true);
// 设置触摸事件
touchOverlay.setTouchEventListener(new RemoteTouchView.TouchEventListener() {
@Override
public void onTouch(float relX, float relY) {
// 已通过 onMotionEvent 处理
}
@Override
public void onSwipe(float relX1, float relY1, float relX2, float relY2, long duration) {
// 已通过 onMotionEvent 处理
sendSwipeCommand(relX1, relY1, relX2, relY2, duration);
}
@Override
public void onKeyEvent(int keyCode, int action) {
sendKeyCommand(keyCode, action);
}
@Override
public void onLongPress(float relX, float relY) {
// 如果需要高层级的长按,可以保留,但 raw 模式下远程系统会自动识别
// sendLongPressCommand(relX, relY);
}
@Override
public void onMotionEvent(int action, float relX, float relY) {
sendMotionEventCommand(action, relX, relY);
}
});
setupResolutionSpinner();
updateUI(false);
}
private void adjustVideoSize(int videoWidth, int videoHeight, int rotation) {
runOnUiThread(() -> {
if (videoWidth == 0 || videoHeight == 0) return;
int containerWidth = controlPanel.getWidth();
int containerHeight = controlPanel.getHeight();
if (containerWidth == 0 || containerHeight == 0) {
// 如果容器还没测量好,稍后再试
controlPanel.postDelayed(() -> adjustVideoSize(videoWidth, videoHeight, rotation), 100);
return;
}
float videoAspectRatio;
if (rotation == 90 || rotation == 270) {
videoAspectRatio = (float) videoHeight / videoWidth;
} else {
videoAspectRatio = (float) videoWidth / videoHeight;
}
float containerAspectRatio = (float) containerWidth / containerHeight;
int newWidth, newHeight;
if (videoAspectRatio > containerAspectRatio) {
// 视频更宽,以容器宽度为准
newWidth = containerWidth;
newHeight = (int) (containerWidth / videoAspectRatio);
} else {
// 视频更高,以容器高度为准
newWidth = (int) (containerHeight * videoAspectRatio);
newHeight = containerHeight;
}
// 更新视频渲染视图大小
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) remoteVideoView.getLayoutParams();
lp.width = newWidth;
lp.height = newHeight;
remoteVideoView.setLayoutParams(lp);
// 更新触摸层大小,使其与视频区域完全重合
FrameLayout.LayoutParams overlayLp = (FrameLayout.LayoutParams) touchOverlay.getLayoutParams();
overlayLp.width = newWidth;
overlayLp.height = newHeight;
touchOverlay.setLayoutParams(overlayLp);
Log.d(TAG, String.format("Adjusted video view size: %dx%d (video: %dx%d, rotation: %d, container: %dx%d)",
newWidth, newHeight, videoWidth, videoHeight, rotation, containerWidth, containerHeight));
});
}
/** 弹出鉴权输入对话框:选择动态验证码或固定密码,输入后发起连接。 */
private void showAuthDialog() {
String serverUrl = etServerUrl.getText().toString().trim();
String target = etTargetDeviceId.getText().toString().trim();
String myId = etDeviceId.getText().toString().trim();
if (serverUrl.isEmpty() || target.isEmpty() || myId.isEmpty()) {
Toast.makeText(this, "请先填写服务器地址、设备ID和目标设备ID", Toast.LENGTH_SHORT).show();
return;
}
View view = getLayoutInflater().inflate(R.layout.dialog_auth_input, null);
RadioGroup rg = view.findViewById(R.id.rg_auth_type);
EditText et = view.findViewById(R.id.et_auth_value);
TextView tip = view.findViewById(R.id.tv_auth_tip);
rg.setOnCheckedChangeListener((group, checkedId) -> {
if (checkedId == R.id.rb_password) {
et.setHint("请输入固定密码");
tip.setText("固定密码为被控端设置的固定密码");
} else {
et.setHint("请输入动态验证码");
tip.setText("动态验证码为被控端界面显示的 6 位大小写字母与数字组合");
}
});
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("连接鉴权")
.setView(view)
.setNegativeButton("取消", null)
.setPositiveButton("连接", null)
.create();
// 校验失败时不关闭对话框,引导用户重新输入
dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE)
.setOnClickListener(v -> {
boolean isPassword = (rg.getCheckedRadioButtonId() == R.id.rb_password);
String authType = isPassword ? "PASSWORD" : "CODE";
String authValue = et.getText().toString().trim();
if (authValue.isEmpty()) {
Toast.makeText(MainActivity.this, "请输入验证码或密码", Toast.LENGTH_SHORT).show();
return;
}
pendingAuthType = authType;
pendingAuthValue = authValue;
dialog.dismiss();
connectToControlled();
}));
dialog.show();
}
private void connectToControlled() {
String serverUrl = etServerUrl.getText().toString().trim();
targetDeviceId = etTargetDeviceId.getText().toString().trim();
myDeviceId = etDeviceId.getText().toString().trim();
if (serverUrl.isEmpty() || targetDeviceId.isEmpty() || myDeviceId.isEmpty()) {
Toast.makeText(this, "请填写所有字段", Toast.LENGTH_SHORT).show();
return;
}
tvStatus.setText("状态: 正在连接信令服务器...");
// 初始化 WebSocket
wsClient = new WebSocketClient(serverUrl, myDeviceId);
wsClient.setListener(new WebSocketClient.SignalListener() {
@Override
public void onConnected() {
tvStatus.setText("状态: 已连接信令服务器,正在发起连接...");
// 连接成功后初始化 WebRTC 并创建 Offer
initWebRtcAndConnect();
}
@Override
public void onDisconnected() {
tvStatus.setText("状态: 已断开连接");
updateUI(false);
}
@Override
public void onError(String error) {
tvStatus.setText("状态: 连接错误 - " + error);
updateUI(false);
}
@Override
public void onMessage(SignalMessage message) {
handleSignalMessage(message);
}
});
wsClient.connect();
}
private void initWebRtcAndConnect() {
webRtcClient = new WebRtcClient(this, wsClient, myDeviceId);
webRtcClient.initialize(eglBase);
webRtcClient.setConnectionListener(new WebRtcClient.ConnectionListener() {
@Override
public void onConnectionEstablished() {
connectionStartTime = System.currentTimeMillis();
runOnUiThread(() -> {
tvStatus.setText("状态: 已连接 - 远程控制中");
updateUI(true);
touchOverlay.requestFocus();
});
}
@Override
public void onConnectionFailed(String error) {
runOnUiThread(() -> {
tvStatus.setText("状态: 连接失败 - " + error);
updateUI(false);
});
}
@Override
public void onDisconnected() {
runOnUiThread(() -> {
Toast.makeText(MainActivity.this, "网络连接已断开", Toast.LENGTH_SHORT).show();
disconnect();
tvStatus.setText("状态: 远端已断开");
});
}
});
webRtcClient.createOffer(targetDeviceId, remoteVideoView, pendingAuthType, pendingAuthValue);
}
private void handleSignalMessage(SignalMessage message) {
String type = message.getType();
if (type == null) return;
switch (type.toUpperCase()) {
case "ANSWER":
handleAnswer(message);
break;
case "ICE_CANDIDATE":
handleIceCandidate(message);
break;
case "TARGET_OFFLINE":
handleTargetOffline(message);
break;
case "CONNECTION_REJECTED":
handleConnectionRejected(message);
break;
case "REQUEST_ERROR":
case "REQUEST_TIMEOUT":
handleRequestAborted(message);
break;
}
}
private void handleTargetOffline(SignalMessage message) {
String offlineId = message.getToDeviceId();
String payload = message.getPayload();
String text = (payload != null && !payload.isEmpty())
? payload
: "目标被控端不在线,请确认设备已开启并连接服务器";
String finalText = text;
runOnUiThread(() -> {
tvStatus.setText("状态: " + finalText);
Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show();
updateUI(false);
});
Log.w(TAG, "Target device " + offlineId + " is offline");
}
/**
* 被控端拒绝了连接请求:提示用户并复位到未连接状态。
*/
private void handleConnectionRejected(SignalMessage message) {
String targetId = message.getToDeviceId();
String reason = parseReason(message.getPayload());
String finalText = reason;
runOnUiThread(() -> {
tvStatus.setText("状态: " + finalText);
Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show();
updateUI(false);
});
Log.w(TAG, "Connection request to " + targetId + " was rejected: " + reason);
}
/** 解析拒绝原因:优先读取 JSON 中的 reason 字段,否则直接返回原文。 */
private String parseReason(String payload) {
if (payload == null || payload.isEmpty()) {
return "目标被控端拒绝了连接请求";
}
try {
JsonObject obj = gson.fromJson(payload, JsonObject.class);
if (obj != null && obj.has("reason") && !obj.get("reason").isJsonNull()) {
return obj.get("reason").getAsString();
}
} catch (Exception ignored) {
}
return payload;
}
/**
* 连接请求被服务器拒绝REQUEST_ERROR或应答超时REQUEST_TIMEOUT提示并复位。
*/
private void handleRequestAborted(SignalMessage message) {
String payload = message.getPayload();
String text = (payload != null && !payload.isEmpty())
? payload
: "连接请求失败";
String finalText = text;
runOnUiThread(() -> {
tvStatus.setText("状态: " + finalText);
Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show();
updateUI(false);
});
Log.w(TAG, "Connection request aborted: " + finalText);
}
private void handleAnswer(SignalMessage message) {
try {
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
String sdp = payload.get("sdp").getAsString();
if (webRtcClient != null) {
// 被控端已接受连接请求,进入 WebRTC 协商阶段。
runOnUiThread(() -> tvStatus.setText("状态: 被控端已接受连接,正在建立连接..."));
webRtcClient.handleAnswer(sdp);
}
} catch (Exception e) {
Log.e(TAG, "Error parsing answer", e);
}
}
private void handleIceCandidate(SignalMessage message) {
try {
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
String sdpMid = payload.get("sdpMid").getAsString();
int sdpMLineIndex = payload.get("sdpMLineIndex").getAsInt();
String candidate = payload.get("candidate").getAsString();
IceCandidate iceCandidate = new IceCandidate(sdpMid, sdpMLineIndex, candidate);
if (webRtcClient != null) {
webRtcClient.addIceCandidate(iceCandidate);
}
} catch (Exception e) {
Log.e(TAG, "Error parsing ICE candidate", e);
}
}
private void sendTouchCommand(float relX, float relY) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.TOUCH)
.setX(relX)
.setY(relY)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void sendSwipeCommand(float relX1, float relY1, float relX2, float relY2, long duration) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.SWIPE)
.setX1(relX1)
.setY1(relY1)
.setX2(relX2)
.setY2(relY2)
.setDuration(duration)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void sendKeyCommand(int keyCode, int action) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.KEY)
.setKeyCode(keyCode)
.setKeyAction(action)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void sendLongPressCommand(float relX, float relY) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.LONG_PRESS)
.setX(relX)
.setY(relY)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void sendMotionEventCommand(int action, float relX, float relY) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.MOTION_EVENT)
.setMotionAction(action)
.setX(relX)
.setY(relY)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void disconnect() {
if (webRtcClient != null) {
webRtcClient.close();
webRtcClient = null;
}
if (wsClient != null) {
wsClient.disconnect();
wsClient = null;
}
// 重置网速统计基线,避免下次连接首帧显示错误速率。
prevBytesReceived = 0;
prevBytesSent = 0;
prevStatsTime = 0;
connectionStartTime = 0;
remoteVideoView.clearImage();
updateUI(false);
tvStatus.setText("状态: 已停止");
}
/** 分辨率预设(发送给被控端的 SET_RESOLUTION 参数)。 */
private static class ResolutionPreset {
final String label;
final int width;
final int height;
final int fps;
ResolutionPreset(String label, int width, int height, int fps) {
this.label = label;
this.width = width;
this.height = height;
this.fps = fps;
}
@Override
public String toString() {
return label;
}
}
/** 构建分辨率下拉框,选择后请求被控端切换采集分辨率。 */
private void setupResolutionSpinner() {
List<ResolutionPreset> presets = new ArrayList<>();
presets.add(new ResolutionPreset("原始画质", 0, 0, 0)); // 被控端原生分辨率
presets.add(new ResolutionPreset("1080P", 1920, 0, 0));
presets.add(new ResolutionPreset("720P", 1280, 0, 0));
presets.add(new ResolutionPreset("480P", 854, 0, 0));
ArrayAdapter<ResolutionPreset> adapter = new ArrayAdapter<>(
this, android.R.layout.simple_spinner_item, presets);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinnerResolution.setAdapter(adapter);
spinnerResolution.setSelection(0);
// 先选中默认项再设置监听,避免初始化即触发一次切换。
spinnerResolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
ResolutionPreset preset = (ResolutionPreset) parent.getItemAtPosition(position);
if (webRtcClient != null) {
webRtcClient.requestResolutionChange(preset.width, preset.height, preset.fps);
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
private void updateUI(boolean connected) {
setupPanel.setVisibility(connected ? View.GONE : View.VISIBLE);
controlPanel.setVisibility(connected ? View.VISIBLE : View.GONE);
btnConnect.setEnabled(!connected);
btnDisconnect.setEnabled(connected);
if (spinnerResolution != null) {
spinnerResolution.setEnabled(connected);
}
if (tvStatusControl != null) {
tvStatusControl.setText(connected ? "远程控制中" : "未连接");
}
if (connected) {
statsHandler.post(statsRunnable);
} else {
statsHandler.removeCallbacks(statsRunnable);
}
}
private void updateStats() {
if (webRtcClient != null) {
webRtcClient.getStats(report -> {
long width = 0, height = 0, fps = 0, delay = 0;
String codecName = "-";
double avgDecodeTimeMs = 0;
// 网速 / 连接类型相关
long bytesReceived = 0;
long bytesSent = 0;
String localCandidateId = null;
String remoteCandidateId = null;
boolean hasNominatedPair = false;
// 抖动 / 丢包 / 丢帧
double jitterMs = 0;
String lossRate = "-";
long framesDropped = 0;
// 控制 DataChannel 收发消息数
long dcMessagesSent = 0;
long dcMessagesReceived = 0;
for (org.webrtc.RTCStats stats : report.getStatsMap().values()) {
String type = stats.getType();
if ("inbound-rtp".equals(type) && "video".equals(stats.getMembers().get("kind"))) {
// 分辨率
Object w = stats.getMembers().get("frameWidth");
if (w instanceof Number) width = ((Number) w).longValue();
Object h = stats.getMembers().get("frameHeight");
if (h instanceof Number) height = ((Number) h).longValue();
// 帧率
Object f = stats.getMembers().get("framesPerSecond");
if (f instanceof Number) fps = ((Number) f).longValue();
// 解码耗时 (平均)
Object totalTime = stats.getMembers().get("totalDecodeTime");
Object frames = stats.getMembers().get("framesDecoded");
if (totalTime instanceof Number && frames instanceof Number) {
double t = ((Number) totalTime).doubleValue();
long c = ((Number) frames).longValue();
if (c > 0) {
avgDecodeTimeMs = (t * 1000.0) / c;
}
}
// 解码格式
Object codecId = stats.getMembers().get("codecId");
if (codecId instanceof String) {
org.webrtc.RTCStats codecStats = report.getStatsMap().get(codecId);
if (codecStats != null) {
Object mime = codecStats.getMembers().get("mimeType");
if (mime instanceof String) {
codecName = (String) mime;
if (codecName.startsWith("video/")) {
codecName = codecName.substring(6);
}
}
}
}
// 解码器实现
Object decoder = stats.getMembers().get("decoderImplementation");
if (decoder instanceof String) {
String d = (String) decoder;
codecName = codecName.equals("-") ? d : codecName + " (" + d + ")";
}
// 抖动(秒 -> 毫秒)
Object jitter = stats.getMembers().get("jitter");
if (jitter instanceof Number) {
jitterMs = ((Number) jitter).doubleValue() * 1000;
}
// 丢包率
Object pr = stats.getMembers().get("packetsReceived");
Object pl = stats.getMembers().get("packetsLost");
if (pr instanceof Number && pl instanceof Number) {
long total = ((Number) pr).longValue() + ((Number) pl).longValue();
lossRate = total > 0
? String.format(Locale.getDefault(), "%.1f", ((Number) pl).doubleValue() / total * 100)
: "0.0";
}
// 丢帧
Object fd = stats.getMembers().get("framesDropped");
if (fd instanceof Number) framesDropped = ((Number) fd).longValue();
}
if ("candidate-pair".equals(type)) {
Object nominated = stats.getMembers().get("nominated");
if (Boolean.TRUE.equals(nominated)) {
hasNominatedPair = true;
Object rtt = stats.getMembers().get("currentRoundTripTime");
if (rtt instanceof Number) {
delay = (long) (((Number) rtt).doubleValue() * 1000);
}
// candidate-pair 的 bytesReceived/bytesSent 为整条连接
// (视频 + DataChannel + ICE的累计值用于计算每秒网速。
Object br = stats.getMembers().get("bytesReceived");
if (br instanceof Number) bytesReceived = ((Number) br).longValue();
Object bs = stats.getMembers().get("bytesSent");
if (bs instanceof Number) bytesSent = ((Number) bs).longValue();
Object lc = stats.getMembers().get("localCandidateId");
if (lc instanceof String) localCandidateId = (String) lc;
Object rc = stats.getMembers().get("remoteCandidateId");
if (rc instanceof String) remoteCandidateId = (String) rc;
}
}
if ("data-channel".equals(type)) {
// 仅统计与对端的控制通道
Object label = stats.getMembers().get("label");
if ("control_channel".equals(label)) {
Object ms = stats.getMembers().get("messagesSent");
if (ms instanceof Number) dcMessagesSent = ((Number) ms).longValue();
Object mr = stats.getMembers().get("messagesReceived");
if (mr instanceof Number)
dcMessagesReceived = ((Number) mr).longValue();
}
}
}
// 计算每秒上下行网速(与上次采样差值)。
long now = System.currentTimeMillis();
String downSpeed = "-";
String upSpeed = "-";
if (prevStatsTime != 0) {
double dt = (now - prevStatsTime) / 1000.0;
if (dt > 0) {
double downBps = (bytesReceived - prevBytesReceived) / dt;
double upBps = (bytesSent - prevBytesSent) / dt;
downSpeed = formatSpeed(downBps);
upSpeed = formatSpeed(upBps);
}
}
prevBytesReceived = bytesReceived;
prevBytesSent = bytesSent;
prevStatsTime = now;
// 判定连接类型与传输协议:两端候选任一为 relay 即走 TURN 中继,否则为 P2P 直连。
String connType = "-";
String protocol = "-";
if (hasNominatedPair) {
connType = resolveConnectionType(report, localCandidateId, remoteCandidateId);
protocol = resolveProtocol(report, localCandidateId, remoteCandidateId);
}
// 连接时长mm:ss
String duration = "-";
if (connectionStartTime != 0) {
long secs = (now - connectionStartTime) / 1000;
duration = String.format(Locale.getDefault(), "%02d:%02d", secs / 60, secs % 60);
}
// 控制 DataChannel 状态
String dcStatus = (webRtcClient != null && webRtcClient.isDataChannelOpen())
? "已连接" : "未连接";
final String statsText = String.format(Locale.getDefault(),
"分辨率: %dx%d 帧率: %d 延迟: %dms\n"
+ "解码格式: %s 抖动: %.0fms 丢包: %s%% 解码耗时: %.1fms\n"
+ "丢帧: %d ↓下载: %s ↑上传: %s\n"
+ "连接类型: %s (%s) 时长: %s\n"
+ "控制通道: %s 收发: %d/%d",
width, height, fps, delay, codecName, jitterMs, lossRate, avgDecodeTimeMs,
framesDropped, downSpeed, upSpeed, connType, protocol,
duration, dcStatus, dcMessagesSent, dcMessagesReceived);
runOnUiThread(() -> {
if (tvStats != null) {
tvStats.setText(statsText);
}
});
});
}
}
// 依据选定候选对的本地/远端候选类型,返回 P2P / TURN 中继描述。
private String resolveConnectionType(org.webrtc.RTCStatsReport report,
String localCandidateId,
String remoteCandidateId) {
String local = candidateTypeOf(report, localCandidateId);
String remote = candidateTypeOf(report, remoteCandidateId);
boolean isRelay = "relay".equals(local) || "relay".equals(remote);
if (isRelay) {
if ("relay".equals(local)) return "TURN 中继 (本端经服务器)";
return "TURN 中继";
}
// host: 同一局域网直连; srflx/prflx: 经 NAT 打洞的 P2P。
return "P2P 直连";
}
// 返回选定候选对的传输协议udp/tcp
private String resolveProtocol(org.webrtc.RTCStatsReport report,
String localCandidateId,
String remoteCandidateId) {
String protoOf = candidateMemberOf(report, localCandidateId, "protocol");
String protoOfRemote = candidateMemberOf(report, remoteCandidateId, "protocol");
if (!"-".equals(protoOf) && !"-".equals(protoOfRemote) && !protoOf.equals(protoOfRemote)) {
return protoOf + "/" + protoOfRemote;
}
return !"-".equals(protoOf) ? protoOf : protoOfRemote;
}
private String candidateTypeOf(org.webrtc.RTCStatsReport report, String candidateId) {
return candidateMemberOf(report, candidateId, "candidateType");
}
private String candidateMemberOf(org.webrtc.RTCStatsReport report, String candidateId, String key) {
if (candidateId == null) return "-";
org.webrtc.RTCStats candidate = report.getStatsMap().get(candidateId);
if (candidate == null) return "-";
Object value = candidate.getMembers().get(key);
return value instanceof String ? (String) value : "-";
}
// 将字节/秒格式化为人类可读字符串B/s、KB/s、MB/s
private String formatSpeed(double bytesPerSecond) {
if (bytesPerSecond >= 1024 * 1024) {
return String.format(Locale.getDefault(), "%.1f MB/s", bytesPerSecond / (1024 * 1024));
} else if (bytesPerSecond >= 1024) {
return String.format(Locale.getDefault(), "%.1f KB/s", bytesPerSecond / 1024);
}
return String.format(Locale.getDefault(), "%.0f B/s", bytesPerSecond);
}
@Override
protected void onDestroy() {
super.onDestroy();
disconnect();
if (eglBase != null) {
eglBase.release();
}
}
}