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