From c37cf4884b4ca53b553bb5578823c9aacb417cb9 Mon Sep 17 00:00:00 2001 From: xiao12feng8 <16507319+xiao12feng8@user.noreply.gitee.com> Date: Tue, 30 Dec 2025 16:08:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E5=92=8C=E8=A7=86=E9=A2=91=E9=80=9A=E8=AF=9D?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zbkj/front/controller/CallController.java | 1 - .../front/controller/LiveRoomController.java | 5 +- .../front/websocket/CallSignalingHandler.java | 129 +++- .../src/main/resources/application.yml | 3 +- android-app/app/build.gradle.kts | 4 + android-app/app/src/main/AndroidManifest.xml | 7 + .../example/livestreaming/MainActivity.java | 4 + .../livestreaming/call/CallActivity.java | 480 +++++++++++- .../livestreaming/call/CallManager.java | 117 ++- .../call/CallSignalingClient.java | 80 +- .../livestreaming/call/WebRTCClient.java | 729 ++++++++++++++++++ .../livestreaming/call/WebRTCConfig.java | 52 ++ .../app/src/main/res/layout/activity_call.xml | 15 +- android-app/gradle.properties | 2 + 14 files changed, 1561 insertions(+), 67 deletions(-) create mode 100644 android-app/app/src/main/java/com/example/livestreaming/call/WebRTCClient.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/call/WebRTCConfig.java diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/CallController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/CallController.java index 41134a81..21da4512 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/CallController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/CallController.java @@ -229,4 +229,3 @@ public class CallController { return response; } } - \ No newline at end of file diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java index 8c383ec9..c236ce29 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java @@ -259,10 +259,7 @@ public class LiveRoomController { } } - // ========== 关注主播接口 ========== - - @Autowired - private com.zbkj.service.service.FollowRecordService followRecordService; + // ========== 直播控制接口 ========== @ApiOperation(value = "开始直播") @PostMapping("/room/{id}/start") diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/websocket/CallSignalingHandler.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/websocket/CallSignalingHandler.java index 9086d9b7..4fa5c092 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/websocket/CallSignalingHandler.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/websocket/CallSignalingHandler.java @@ -53,6 +53,8 @@ public class CallSignalingHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { + System.out.println("[CallSignaling] ========== 连接建立 =========="); + System.out.println("[CallSignaling] sessionId=" + session.getId()); logger.info("[CallSignaling] 连接建立: sessionId={}", session.getId()); } @@ -63,6 +65,9 @@ public class CallSignalingHandler extends TextWebSocketHandler { String type = json.has("type") ? json.get("type").asText() : ""; switch (type) { + case "ping": + handlePing(session); + break; case "register": handleRegister(session, json); break; @@ -97,6 +102,9 @@ public class CallSignalingHandler extends TextWebSocketHandler { private void handleRegister(WebSocketSession session, JsonNode json) throws IOException { Integer userId = json.has("userId") ? json.get("userId").asInt() : null; + System.out.println("[CallSignaling] ========== 用户注册 =========="); + System.out.println("[CallSignaling] userId=" + userId); + if (userId == null) { sendError(session, "userId不能为空"); return; @@ -105,6 +113,7 @@ public class CallSignalingHandler extends TextWebSocketHandler { // 关闭旧连接 WebSocketSession oldSession = userCallSessions.get(userId); if (oldSession != null && oldSession.isOpen() && !oldSession.getId().equals(session.getId())) { + System.out.println("[CallSignaling] 关闭旧连接: userId=" + userId); logger.info("[CallSignaling] 关闭旧连接: userId={}, oldSessionId={}", userId, oldSession.getId()); try { oldSession.close(); @@ -118,6 +127,8 @@ public class CallSignalingHandler extends TextWebSocketHandler { response.put("type", "registered"); response.put("userId", userId); session.sendMessage(new TextMessage(response.toString())); + + System.out.println("[CallSignaling] 用户注册成功: userId=" + userId + ", 当前在线用户=" + userCallSessions.keySet()); logger.info("[CallSignaling] 用户注册成功: userId={}, sessionId={}, 当前在线用户数={}", userId, session.getId(), userCallSessions.size()); } @@ -328,15 +339,38 @@ public class CallSignalingHandler extends TextWebSocketHandler { private void handleSignaling(WebSocketSession session, JsonNode json, String type) throws IOException { String callId = json.has("callId") ? json.get("callId").asText() : sessionCallMap.get(session.getId()); + Integer senderId = sessionUserMap.get(session.getId()); + + System.out.println("[CallSignaling] ========== 处理信令消息 =========="); + System.out.println("[CallSignaling] type=" + type + ", callId=" + callId + ", senderId=" + senderId); + System.out.println("[CallSignaling] senderSessionId=" + session.getId()); + if (callId == null) { sendError(session, "callId不能为空"); return; } + // 确保通话会话存在 Set sessions = callSessions.get(callId); if (sessions == null) { - sendError(session, "通话不存在"); - return; + // 尝试创建会话(可能是REST API发起的通话) + System.out.println("[CallSignaling] 通话会话不存在,创建新会话: callId=" + callId); + logger.warn("[CallSignaling] 通话会话不存在,尝试创建: callId={}", callId); + sessions = callSessions.computeIfAbsent(callId, k -> new CopyOnWriteArraySet<>()); + } + + System.out.println("[CallSignaling] 当前会话中的参与者数量: " + sessions.size()); + for (WebSocketSession s : sessions) { + Integer userId = sessionUserMap.get(s.getId()); + System.out.println("[CallSignaling] - sessionId=" + s.getId() + ", userId=" + userId + ", isOpen=" + s.isOpen()); + } + + // 确保当前用户在通话会话中 + if (!sessions.contains(session)) { + sessions.add(session); + sessionCallMap.put(session.getId(), callId); + System.out.println("[CallSignaling] 将发送者加入通话会话: sessionId=" + session.getId() + ", userId=" + senderId); + logger.info("[CallSignaling] 将用户加入通话会话: callId={}, sessionId={}", callId, session.getId()); } // 转发信令给通话中的其他参与者 @@ -351,13 +385,22 @@ public class CallSignalingHandler extends TextWebSocketHandler { } String forwardMsg = forward.toString(); + int forwardCount = 0; for (WebSocketSession s : sessions) { - if (s.isOpen() && !s.getId().equals(session.getId())) { + Integer targetUserId = sessionUserMap.get(s.getId()); + boolean isSender = s.getId().equals(session.getId()); + System.out.println("[CallSignaling] 检查转发目标: targetSessionId=" + s.getId() + + ", targetUserId=" + targetUserId + ", isOpen=" + s.isOpen() + ", isSender=" + isSender); + + if (s.isOpen() && !isSender) { s.sendMessage(new TextMessage(forwardMsg)); + forwardCount++; + System.out.println("[CallSignaling] >>> 已转发给: userId=" + targetUserId); } } - logger.debug("[CallSignaling] 转发信令: type={}, callId={}", type, callId); + System.out.println("[CallSignaling] 转发完成: type=" + type + ", 转发给" + forwardCount + "个用户"); + logger.info("[CallSignaling] 转发信令: type={}, callId={}, senderId={}, 转发给{}个用户", type, callId, senderId, forwardCount); } @Override @@ -461,11 +504,49 @@ public class CallSignalingHandler extends TextWebSocketHandler { return "calling".equals(status) || "ringing".equals(status) || "connected".equals(status); } - private void sendError(WebSocketSession session, String message) throws IOException { - ObjectNode error = objectMapper.createObjectNode(); - error.put("type", "error"); - error.put("message", message); - session.sendMessage(new TextMessage(error.toString())); + /** + * 处理心跳 ping 消息 + */ + private void handlePing(WebSocketSession session) { + try { + if (session != null && session.isOpen()) { + ObjectNode pong = objectMapper.createObjectNode(); + pong.put("type", "pong"); + session.sendMessage(new TextMessage(pong.toString())); + } + } catch (Exception e) { + logger.warn("[CallSignaling] 发送 pong 失败: {}", e.getMessage()); + } + } + + private void sendError(WebSocketSession session, String message) { + try { + if (session != null && session.isOpen()) { + synchronized (session) { + ObjectNode error = objectMapper.createObjectNode(); + error.put("type", "error"); + error.put("message", message); + session.sendMessage(new TextMessage(error.toString())); + } + } + } catch (Exception e) { + logger.warn("[CallSignaling] 发送错误消息失败: {}", e.getMessage()); + } + } + + /** + * 安全发送消息(带同步锁) + */ + private void sendMessage(WebSocketSession session, String message) { + try { + if (session != null && session.isOpen()) { + synchronized (session) { + session.sendMessage(new TextMessage(message)); + } + } + } catch (Exception e) { + logger.warn("[CallSignaling] 发送消息失败: {}", e.getMessage()); + } } /** @@ -473,9 +554,27 @@ public class CallSignalingHandler extends TextWebSocketHandler { */ public void notifyIncomingCall(String callId, Integer callerId, String callerName, String callerAvatar, Integer calleeId, String callType) { + System.out.println("[CallSignaling] ========== 通知来电 =========="); + System.out.println("[CallSignaling] callId=" + callId + ", callerId=" + callerId + ", calleeId=" + calleeId); + System.out.println("[CallSignaling] 当前在线用户=" + userCallSessions.keySet()); + try { // 记录通话创建时间 callCreateTime.put(callId, System.currentTimeMillis()); + + // 初始化通话会话(重要!这样后续的信令才能正确转发) + callSessions.computeIfAbsent(callId, k -> new CopyOnWriteArraySet<>()); + + // 将主叫方加入通话会话 + WebSocketSession callerSession = userCallSessions.get(callerId); + if (callerSession != null && callerSession.isOpen()) { + joinCallSession(callId, callerSession); + sessionCallMap.put(callerSession.getId(), callId); + System.out.println("[CallSignaling] 主叫方加入通话会话: callerId=" + callerId); + logger.info("[CallSignaling] 主叫方加入通话会话: callId={}, callerId={}", callId, callerId); + } else { + System.out.println("[CallSignaling] 主叫方未在线: callerId=" + callerId); + } // 通知被叫方 WebSocketSession calleeSession = userCallSessions.get(calleeId); @@ -488,11 +587,14 @@ public class CallSignalingHandler extends TextWebSocketHandler { incoming.put("callerAvatar", callerAvatar != null ? callerAvatar : ""); incoming.put("callType", callType); calleeSession.sendMessage(new TextMessage(incoming.toString())); + System.out.println("[CallSignaling] 已发送来电通知给被叫方: calleeId=" + calleeId); logger.info("[CallSignaling] 通知来电: callId={}, callee={}", callId, calleeId); } else { - logger.warn("[CallSignaling] 被叫方未在线: calleeId={}", calleeId); + System.out.println("[CallSignaling] !!!!! 被叫方未在线 !!!!! calleeId=" + calleeId); + logger.warn("[CallSignaling] 被叫方未在线: calleeId={}, 当前在线用户={}", calleeId, userCallSessions.keySet()); } } catch (Exception e) { + System.out.println("[CallSignaling] 通知来电异常: " + e.getMessage()); logger.error("[CallSignaling] 通知来电异常: callId={}", callId, e); } } @@ -512,8 +614,15 @@ public class CallSignalingHandler extends TextWebSocketHandler { logger.info("[CallSignaling] REST API调用notifyCallAccepted: callId={}, callerId={}, 当前在线用户={}", callId, callerId, userCallSessions.keySet()); try { + // 确保通话会话存在 + callSessions.computeIfAbsent(callId, k -> new CopyOnWriteArraySet<>()); + WebSocketSession callerSession = userCallSessions.get(callerId); if (callerSession != null && callerSession.isOpen()) { + // 确保主叫方在通话会话中 + joinCallSession(callId, callerSession); + sessionCallMap.put(callerSession.getId(), callId); + ObjectNode notify = objectMapper.createObjectNode(); notify.put("type", "call_accepted"); notify.put("callId", callId); diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml b/Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml index d41cbc6d..9b8ba169 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml +++ b/Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml @@ -84,7 +84,8 @@ debug: true logging: level: io.swagger.*: error - com.zbjk.crmeb: debug + com.zbkj: debug + com.zbkj.front.websocket: info org.springframework.boot.autoconfigure: ERROR config: classpath:logback-spring.xml file: diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 7f3c6fba..524483d9 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -113,4 +113,8 @@ dependencies { implementation("com.github.andnux:ijkplayer:0.0.1") { exclude("com.google.android.exoplayer", "exoplayer") } + + // WebRTC for voice/video calls + // 使用 Google 官方 WebRTC 库 + implementation("io.getstream:stream-webrtc-android:1.1.1") } diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 6eaab2d1..8ec5bcbf 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -12,6 +12,13 @@ + + + + + + + pendingIceCandidates = new ArrayList<>(); + private boolean remoteDescriptionSet = false; + private Runnable durationRunnable = new Runnable() { @Override public void run() { @@ -70,6 +109,8 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + Log.d(TAG, "========== CallActivity onCreate =========="); + Toast.makeText(this, "通话界面已打开", Toast.LENGTH_SHORT).show(); // 保持屏幕常亮,显示在锁屏上方 getWindow().addFlags( @@ -82,9 +123,20 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS initViews(); initData(); - initCallManager(); - setupListeners(); - updateUI(); + + Log.d(TAG, "callId=" + callId + ", callType=" + callType + ", isCaller=" + isCaller); + Toast.makeText(this, "通话类型: " + callType + ", 主叫: " + isCaller, Toast.LENGTH_SHORT).show(); + + // 检查权限 + if (checkPermissions()) { + Log.d(TAG, "权限已授予,初始化通话"); + Toast.makeText(this, "权限OK,初始化WebRTC", Toast.LENGTH_SHORT).show(); + initCallAndWebRTC(); + } else { + Log.d(TAG, "请求权限"); + Toast.makeText(this, "请求权限中...", Toast.LENGTH_SHORT).show(); + requestPermissions(); + } } private void initViews() { @@ -102,9 +154,62 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS btnSwitchCamera = findViewById(R.id.btnSwitchCamera); layoutCallControls = findViewById(R.id.layoutCallControls); layoutVideoToggle = findViewById(R.id.layoutVideoToggle); + layoutLocalVideo = findViewById(R.id.layoutLocalVideo); + layoutRemoteVideo = findViewById(R.id.layoutRemoteVideo); handler = new Handler(Looper.getMainLooper()); audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + + // 设置音频模式为通话模式(重要!WebRTC音频需要此设置) + setupAudioForCall(); + + setupListeners(); + } + + /** + * 设置音频为通话模式 + */ + private void setupAudioForCall() { + Log.d(TAG, "========== 设置音频模式 =========="); + try { + // 保存原始音频模式 + int originalMode = audioManager.getMode(); + Log.d(TAG, "原始音频模式: " + originalMode); + + // 设置为通话模式 + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + Log.d(TAG, "设置音频模式为 MODE_IN_COMMUNICATION"); + + // 请求音频焦点 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + android.media.AudioAttributes playbackAttributes = new android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH) + .build(); + android.media.AudioFocusRequest focusRequest = new android.media.AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + .setAudioAttributes(playbackAttributes) + .build(); + audioManager.requestAudioFocus(focusRequest); + } else { + audioManager.requestAudioFocus(null, AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); + } + Log.d(TAG, "已请求音频焦点"); + + // 默认使用听筒(语音通话)或扬声器(视频通话) + if ("video".equals(callType)) { + audioManager.setSpeakerphoneOn(true); + isSpeakerOn = true; + Log.d(TAG, "视频通话,默认开启扬声器"); + } else { + audioManager.setSpeakerphoneOn(false); + isSpeakerOn = false; + Log.d(TAG, "语音通话,默认使用听筒"); + } + + Log.d(TAG, "音频设置完成"); + } catch (Exception e) { + Log.e(TAG, "设置音频模式失败", e); + } } private void initData() { @@ -118,36 +223,175 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS // 如果是被叫方接听,直接进入通话状态 boolean alreadyConnected = getIntent().getBooleanExtra("isConnected", false); if (alreadyConnected) { - android.util.Log.d("CallActivity", "被叫方接听,直接进入通话状态"); + Log.d(TAG, "被叫方接听,直接进入通话状态"); isConnected = true; callStartTime = System.currentTimeMillis(); } + + updateUI(); + } + + private boolean checkPermissions() { + boolean audioPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + boolean cameraPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + + if ("video".equals(callType)) { + return audioPermission && cameraPermission; + } + return audioPermission; + } + + private void requestPermissions() { + List permissions = new ArrayList<>(); + permissions.add(Manifest.permission.RECORD_AUDIO); + if ("video".equals(callType)) { + permissions.add(Manifest.permission.CAMERA); + } + ActivityCompat.requestPermissions(this, permissions.toArray(new String[0]), PERMISSION_REQUEST_CODE); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == PERMISSION_REQUEST_CODE) { + boolean allGranted = true; + for (int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + if (allGranted) { + initCallAndWebRTC(); + } else { + Toast.makeText(this, "需要麦克风和摄像头权限才能进行通话", Toast.LENGTH_LONG).show(); + finish(); + } + } + } + + private void initCallAndWebRTC() { + initCallManager(); + initWebRTC(); } private void initCallManager() { callManager = CallManager.getInstance(this); callManager.setStateListener(this); - // 确保WebSocket已连接(主叫方需要接收接听/拒绝通知) + // 确保WebSocket已连接 String userId = com.example.livestreaming.net.AuthStore.getUserId(this); if (userId != null && !userId.isEmpty()) { try { int uid = (int) Double.parseDouble(userId); if (uid > 0) { - android.util.Log.d("CallActivity", "确保WebSocket连接,userId: " + uid); + Log.d(TAG, "确保WebSocket连接,userId: " + uid); callManager.connect(uid); } } catch (NumberFormatException e) { - android.util.Log.e("CallActivity", "解析用户ID失败", e); + Log.e(TAG, "解析用户ID失败", e); } } } + private void initWebRTC() { + Log.d(TAG, "========== 初始化 WebRTC =========="); + Toast.makeText(this, "开始初始化WebRTC...", Toast.LENGTH_SHORT).show(); + + boolean isVideoCall = "video".equals(callType); + Log.d(TAG, "isVideoCall=" + isVideoCall + ", callType=" + callType); + + try { + // 创建 WebRTC 客户端 + Toast.makeText(this, "创建WebRTC客户端...", Toast.LENGTH_SHORT).show(); + webRTCClient = new WebRTCClient(this); + webRTCClient.setListener(this); + + Toast.makeText(this, "调用initialize...", Toast.LENGTH_SHORT).show(); + webRTCClient.initialize(isVideoCall); + + Log.d(TAG, "WebRTC 客户端创建成功"); + Toast.makeText(this, "initialize完成!", Toast.LENGTH_SHORT).show(); + + // 如果是视频通话,设置视频渲染器 + if (isVideoCall) { + try { + Toast.makeText(this, "设置视频渲染器...", Toast.LENGTH_SHORT).show(); + setupVideoRenderers(); + Toast.makeText(this, "视频渲染器设置成功", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "视频渲染器设置失败", e); + Toast.makeText(this, "视频渲染器失败: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + // 创建本地媒体流 + Log.d(TAG, "创建本地媒体流..."); + try { + Toast.makeText(this, "创建本地媒体流...", Toast.LENGTH_SHORT).show(); + webRTCClient.createLocalStream(); + Toast.makeText(this, "本地媒体流创建成功", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "本地媒体流创建失败", e); + Toast.makeText(this, "媒体流失败: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + + // 创建 PeerConnection + Log.d(TAG, "创建 PeerConnection..."); + try { + webRTCClient.createPeerConnection(); + Toast.makeText(this, "PeerConnection创建成功", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "PeerConnection创建失败", e); + Toast.makeText(this, "PeerConnection失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); + return; + } + + isWebRTCInitialized = true; + Log.d(TAG, "WebRTC 初始化完成"); + Toast.makeText(this, "WebRTC初始化完成!", Toast.LENGTH_SHORT).show(); + + // 主叫方:等待对方接听后再创建 Offer(在 onCallConnected 中创建) + // 被叫方:等待收到 Offer 后创建 Answer + if (isCaller) { + Log.d(TAG, "主叫方,等待对方接听后创建 Offer"); + Toast.makeText(this, "等待对方接听...", Toast.LENGTH_SHORT).show(); + } else { + Log.d(TAG, "被叫方,等待 Offer"); + Toast.makeText(this, "被叫方,等待Offer...", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, "WebRTC 初始化失败", e); + Toast.makeText(this, "WebRTC初始化失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); + } + } + + private void setupVideoRenderers() { + Log.d(TAG, "设置视频渲染器"); + + // 创建本地视频渲染器 + localRenderer = new SurfaceViewRenderer(this); + layoutLocalVideo.addView(localRenderer); + webRTCClient.setLocalRenderer(localRenderer); + + // 创建远程视频渲染器 + remoteRenderer = new SurfaceViewRenderer(this); + layoutRemoteVideo.addView(remoteRenderer); + webRTCClient.setRemoteRenderer(remoteRenderer); + + // 显示视频布局 + layoutLocalVideo.setVisibility(View.VISIBLE); + layoutRemoteVideo.setVisibility(View.VISIBLE); + + // 隐藏头像(视频通话时) + ivBackgroundAvatar.setVisibility(View.GONE); + ivAvatar.setVisibility(View.GONE); + } + private void setupListeners() { - btnMinimize.setOnClickListener(v -> { - // 最小化通话(后台运行) - moveTaskToBack(true); - }); + btnMinimize.setOnClickListener(v -> moveTaskToBack(true)); btnMute.setOnClickListener(v -> toggleMute()); btnSpeaker.setOnClickListener(v -> toggleSpeaker()); @@ -162,7 +406,7 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS } else { callManager.rejectCall(callId); } - finish(); + releaseAndFinish(); }); } @@ -183,14 +427,11 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS // 根据连接状态设置界面 if (isConnected) { - // 已接通,显示通话中界面 tvCallStatus.setVisibility(View.GONE); tvCallDuration.setVisibility(View.VISIBLE); layoutCallControls.setVisibility(View.VISIBLE); handler.post(durationRunnable); - android.util.Log.d("CallActivity", "updateUI: 已接通状态,显示计时器"); } else { - // 未接通,显示等待状态 if (isCaller) { tvCallStatus.setText("正在呼叫..."); } else { @@ -203,7 +444,10 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS isMuted = !isMuted; btnMute.setImageResource(isMuted ? R.drawable.ic_mic_off : R.drawable.ic_mic); btnMute.setBackgroundResource(isMuted ? R.drawable.bg_call_button_active : R.drawable.bg_call_button); - // TODO: 实际静音控制 + + if (webRTCClient != null) { + webRTCClient.setMuted(isMuted); + } Toast.makeText(this, isMuted ? "已静音" : "已取消静音", Toast.LENGTH_SHORT).show(); } @@ -217,17 +461,35 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS private void toggleVideo() { isVideoEnabled = !isVideoEnabled; btnVideo.setBackgroundResource(isVideoEnabled ? R.drawable.bg_call_button : R.drawable.bg_call_button_active); - // TODO: 实际视频控制 + + if (webRTCClient != null) { + webRTCClient.setVideoEnabled(isVideoEnabled); + } + + if (localRenderer != null) { + localRenderer.setVisibility(isVideoEnabled ? View.VISIBLE : View.GONE); + } Toast.makeText(this, isVideoEnabled ? "已开启摄像头" : "已关闭摄像头", Toast.LENGTH_SHORT).show(); } private void switchCamera() { - // TODO: 切换前后摄像头 + if (webRTCClient != null) { + webRTCClient.switchCamera(); + } Toast.makeText(this, "切换摄像头", Toast.LENGTH_SHORT).show(); } private void onCallConnected() { - android.util.Log.d("CallActivity", "onCallConnected() 开始执行"); + Log.d(TAG, "========== 通话已连接 =========="); + Log.d(TAG, "isCaller=" + isCaller + ", isWebRTCInitialized=" + isWebRTCInitialized); + + // 如果是主叫方收到 call_accepted,现在创建 Offer + if (isCaller && isWebRTCInitialized && webRTCClient != null) { + Log.d(TAG, "主叫方收到接听通知,开始创建 Offer"); + Toast.makeText(this, "对方已接听,建立连接...", Toast.LENGTH_SHORT).show(); + webRTCClient.createOffer(); + } + isConnected = true; callStartTime = System.currentTimeMillis(); @@ -236,14 +498,22 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS layoutCallControls.setVisibility(View.VISIBLE); handler.post(durationRunnable); - android.util.Log.d("CallActivity", "onCallConnected() 执行完成,通话已连接"); Toast.makeText(this, "通话已接通", Toast.LENGTH_SHORT).show(); } - // CallStateListener 实现 + private void releaseAndFinish() { + if (webRTCClient != null) { + webRTCClient.release(); + webRTCClient = null; + } + finish(); + } + + // ==================== CallStateListener 实现 ==================== + @Override public void onCallStateChanged(String state, String callId) { - // 状态变化处理 + Log.d(TAG, "onCallStateChanged: " + state); } @Override @@ -253,13 +523,8 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS @Override public void onCallConnected(String callId) { - android.util.Log.d("CallActivity", "========== onCallConnected 被调用 =========="); - android.util.Log.d("CallActivity", "callId: " + callId); - android.util.Log.d("CallActivity", "this.callId: " + this.callId); - runOnUiThread(() -> { - android.util.Log.d("CallActivity", "执行 onCallConnected UI更新"); - onCallConnected(); - }); + Log.d(TAG, "onCallConnected: " + callId); + runOnUiThread(this::onCallConnected); } @Override @@ -286,7 +551,7 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS message = "通话已结束"; } Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); - finish(); + releaseAndFinish(); }); } @@ -297,18 +562,163 @@ public class CallActivity extends AppCompatActivity implements CallManager.CallS }); } + // ==================== WebRTCListener 实现 ==================== + + @Override + public void onLocalStream(MediaStream stream) { + Log.d(TAG, "本地媒体流已创建"); + } + + @Override + public void onRemoteStream(MediaStream stream) { + Log.d(TAG, "收到远程媒体流"); + runOnUiThread(() -> { + if (!isConnected) { + onCallConnected(); + } + }); + } + + @Override + public void onIceCandidate(IceCandidate candidate) { + Log.d(TAG, "========== 发送 ICE Candidate =========="); + // 通过信令服务器发送 ICE Candidate + if (callManager != null && callId != null) { + try { + JSONObject candidateJson = new JSONObject(); + candidateJson.put("sdpMid", candidate.sdpMid); + candidateJson.put("sdpMLineIndex", candidate.sdpMLineIndex); + candidateJson.put("candidate", candidate.sdp); + callManager.sendIceCandidate(callId, candidateJson); + Log.d(TAG, "ICE Candidate 已发送"); + } catch (JSONException e) { + Log.e(TAG, "构建 ICE Candidate JSON 失败", e); + } + } else { + Log.e(TAG, "无法发送ICE: callManager=" + callManager + ", callId=" + callId); + } + } + + @Override + public void onIceConnectionChange(PeerConnection.IceConnectionState state) { + Log.d(TAG, "ICE 连接状态变化: " + state); + runOnUiThread(() -> { + switch (state) { + case CONNECTED: + case COMPLETED: + if (!isConnected) { + onCallConnected(); + } + break; + case DISCONNECTED: + case FAILED: + Toast.makeText(this, "连接断开", Toast.LENGTH_SHORT).show(); + releaseAndFinish(); + break; + } + }); + } + + @Override + public void onOfferCreated(SessionDescription sdp) { + Log.d(TAG, "========== Offer 已创建 =========="); + Log.d(TAG, "callId=" + callId); + Log.d(TAG, "callManager=" + (callManager != null ? "存在" : "null")); + + if (callManager != null && callId != null) { + callManager.sendOffer(callId, sdp.description); + Log.d(TAG, "Offer 已发送"); + runOnUiThread(() -> Toast.makeText(this, "Offer已发送!", Toast.LENGTH_SHORT).show()); + } else { + Log.e(TAG, "无法发送Offer: callManager=" + callManager + ", callId=" + callId); + runOnUiThread(() -> Toast.makeText(this, "发送Offer失败!", Toast.LENGTH_SHORT).show()); + } + } + + @Override + public void onAnswerCreated(SessionDescription sdp) { + Log.d(TAG, "Answer 已创建,发送给对方"); + if (callManager != null && callId != null) { + callManager.sendAnswer(callId, sdp.description); + Log.d(TAG, "Answer 已发送"); + } + } + + // 处理收到的 Offer + public void handleRemoteOffer(String sdp) { + Log.d(TAG, "收到远程 Offer"); + if (webRTCClient != null) { + SessionDescription offer = new SessionDescription(SessionDescription.Type.OFFER, sdp); + webRTCClient.setRemoteDescription(offer); + remoteDescriptionSet = true; + + // 处理缓存的 ICE Candidates + for (IceCandidate candidate : pendingIceCandidates) { + webRTCClient.addIceCandidate(candidate); + } + pendingIceCandidates.clear(); + } + } + + // 处理收到的 Answer + public void handleRemoteAnswer(String sdp) { + Log.d(TAG, "收到远程 Answer"); + if (webRTCClient != null) { + SessionDescription answer = new SessionDescription(SessionDescription.Type.ANSWER, sdp); + webRTCClient.setRemoteDescription(answer); + remoteDescriptionSet = true; + + // 处理缓存的 ICE Candidates + for (IceCandidate candidate : pendingIceCandidates) { + webRTCClient.addIceCandidate(candidate); + } + pendingIceCandidates.clear(); + } + } + + // 处理收到的 ICE Candidate + public void handleRemoteIceCandidate(JSONObject candidateJson) { + try { + String sdpMid = candidateJson.getString("sdpMid"); + int sdpMLineIndex = candidateJson.getInt("sdpMLineIndex"); + String sdp = candidateJson.getString("candidate"); + + IceCandidate candidate = new IceCandidate(sdpMid, sdpMLineIndex, sdp); + + if (remoteDescriptionSet && webRTCClient != null) { + webRTCClient.addIceCandidate(candidate); + } else { + // 缓存 ICE Candidate,等远程 SDP 设置后再添加 + pendingIceCandidates.add(candidate); + } + } catch (JSONException e) { + Log.e(TAG, "解析 ICE Candidate 失败", e); + } + } + @Override protected void onDestroy() { super.onDestroy(); handler.removeCallbacks(durationRunnable); + + // 恢复音频模式 + if (audioManager != null) { + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.setSpeakerphoneOn(false); + Log.d(TAG, "音频模式已恢复"); + } + if (callManager != null) { callManager.setStateListener(null); } + if (webRTCClient != null) { + webRTCClient.release(); + webRTCClient = null; + } } @Override public void onBackPressed() { - // 禁止返回键退出,需要点击挂断 moveTaskToBack(true); } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallManager.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallManager.java index 31b4835b..51c9c979 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/call/CallManager.java +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallManager.java @@ -351,18 +351,86 @@ public class CallManager implements CallSignalingClient.SignalingListener { // 启动来电界面 Log.d(TAG, "启动来电界面 IncomingCallActivity"); Intent intent = new Intent(context, IncomingCallActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + // 添加多个 flags 确保能从后台启动 + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_SINGLE_TOP + | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); intent.putExtra("callId", callId); intent.putExtra("callType", callType); intent.putExtra("callerId", callerId); intent.putExtra("callerName", callerName); intent.putExtra("callerAvatar", callerAvatar); - context.startActivity(intent); + + try { + context.startActivity(intent); + Log.d(TAG, "来电界面启动成功"); + } catch (Exception e) { + Log.e(TAG, "启动来电界面失败: " + e.getMessage(), e); + // 如果直接启动失败,尝试使用通知方式 + showIncomingCallNotification(callId, callerId, callerName, callerAvatar, callType); + } if (stateListener != null) { stateListener.onIncomingCall(callId, callerId, callerName, callerAvatar, callType); } } + + /** + * 显示来电通知(当无法直接启动Activity时使用) + */ + private void showIncomingCallNotification(String callId, int callerId, String callerName, String callerAvatar, String callType) { + try { + android.app.NotificationManager notificationManager = + (android.app.NotificationManager) context.getSystemService(android.content.Context.NOTIFICATION_SERVICE); + + // 创建通知渠道 (Android 8.0+) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + android.app.NotificationChannel channel = new android.app.NotificationChannel( + "incoming_call", + "来电通知", + android.app.NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("显示来电通知"); + channel.enableVibration(true); + channel.setLockscreenVisibility(android.app.Notification.VISIBILITY_PUBLIC); + notificationManager.createNotificationChannel(channel); + } + + // 创建点击通知时启动的Intent + Intent intent = new Intent(context, IncomingCallActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra("callId", callId); + intent.putExtra("callType", callType); + intent.putExtra("callerId", callerId); + intent.putExtra("callerName", callerName); + intent.putExtra("callerAvatar", callerAvatar); + + android.app.PendingIntent pendingIntent = android.app.PendingIntent.getActivity( + context, 0, intent, + android.app.PendingIntent.FLAG_UPDATE_CURRENT | android.app.PendingIntent.FLAG_IMMUTABLE + ); + + // 构建通知 + String callTypeText = "video".equals(callType) ? "视频通话" : "语音通话"; + androidx.core.app.NotificationCompat.Builder builder = + new androidx.core.app.NotificationCompat.Builder(context, "incoming_call") + .setSmallIcon(android.R.drawable.ic_menu_call) + .setContentTitle(callerName + " 的" + callTypeText) + .setContentText("点击接听") + .setPriority(androidx.core.app.NotificationCompat.PRIORITY_HIGH) + .setCategory(androidx.core.app.NotificationCompat.CATEGORY_CALL) + .setFullScreenIntent(pendingIntent, true) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setOngoing(true); + + notificationManager.notify(1001, builder.build()); + Log.d(TAG, "来电通知已显示"); + } catch (Exception e) { + Log.e(TAG, "显示来电通知失败: " + e.getMessage(), e); + } + } @Override public void onCallAccepted(String callId) { @@ -417,17 +485,56 @@ public class CallManager implements CallSignalingClient.SignalingListener { @Override public void onOffer(String callId, String sdp) { - // WebRTC offer处理 - 后续实现 + Log.d(TAG, "收到 Offer"); + // 通知 CallActivity 处理 Offer + if (stateListener instanceof CallActivity) { + ((CallActivity) stateListener).handleRemoteOffer(sdp); + } } @Override public void onAnswer(String callId, String sdp) { - // WebRTC answer处理 - 后续实现 + Log.d(TAG, "收到 Answer"); + // 通知 CallActivity 处理 Answer + if (stateListener instanceof CallActivity) { + ((CallActivity) stateListener).handleRemoteAnswer(sdp); + } } @Override public void onIceCandidate(String callId, JSONObject candidate) { - // WebRTC ICE candidate处理 - 后续实现 + Log.d(TAG, "收到 ICE Candidate"); + // 通知 CallActivity 处理 ICE Candidate + if (stateListener instanceof CallActivity) { + ((CallActivity) stateListener).handleRemoteIceCandidate(candidate); + } + } + + /** + * 发送 Offer + */ + public void sendOffer(String callId, String sdp) { + if (signalingClient != null) { + signalingClient.sendOffer(callId, sdp); + } + } + + /** + * 发送 Answer + */ + public void sendAnswer(String callId, String sdp) { + if (signalingClient != null) { + signalingClient.sendAnswer(callId, sdp); + } + } + + /** + * 发送 ICE Candidate + */ + public void sendIceCandidate(String callId, JSONObject candidate) { + if (signalingClient != null) { + signalingClient.sendIceCandidate(callId, candidate); + } } public interface CallCallback { diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallSignalingClient.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallSignalingClient.java index 43706c24..bc0380ab 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/call/CallSignalingClient.java +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallSignalingClient.java @@ -26,6 +26,12 @@ public class CallSignalingClient { private String baseUrl; private int userId; private boolean isConnected = false; + private boolean isManualDisconnect = false; + private int reconnectAttempts = 0; + private static final int MAX_RECONNECT_ATTEMPTS = 5; + private static final long RECONNECT_DELAY_MS = 3000; + private static final long HEARTBEAT_INTERVAL_MS = 30000; // 30秒心跳 + private Runnable heartbeatRunnable; public interface SignalingListener { void onConnected(); @@ -54,19 +60,26 @@ public class CallSignalingClient { } public void connect() { + isManualDisconnect = false; + doConnect(); + } + + private void doConnect() { String wsUrl = baseUrl.replace("http://", "ws://").replace("https://", "wss://"); if (!wsUrl.endsWith("/")) wsUrl += "/"; wsUrl += "ws/call"; - Log.d(TAG, "Connecting to: " + wsUrl); + Log.d(TAG, "Connecting to: " + wsUrl + " (attempt " + (reconnectAttempts + 1) + ")"); Request request = new Request.Builder().url(wsUrl).build(); webSocket = client.newWebSocket(request, new WebSocketListener() { @Override public void onOpen(WebSocket webSocket, Response response) { - Log.d(TAG, "WebSocket connected"); + Log.d(TAG, "WebSocket connected successfully!"); isConnected = true; + reconnectAttempts = 0; // 重置重连计数 register(); + startHeartbeat(); notifyConnected(); } @@ -86,24 +99,83 @@ public class CallSignalingClient { Log.d(TAG, "WebSocket closed: " + reason); isConnected = false; notifyDisconnected(); + // 非手动断开时尝试重连 + if (!isManualDisconnect) { + scheduleReconnect(); + } } @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { - Log.e(TAG, "WebSocket error", t); + Log.e(TAG, "WebSocket connection failed: " + t.getMessage(), t); isConnected = false; - notifyError(t.getMessage()); + notifyError("连接失败: " + t.getMessage()); + // 尝试重连 + if (!isManualDisconnect) { + scheduleReconnect(); + } } }); } + + private void scheduleReconnect() { + if (isManualDisconnect) { + Log.d(TAG, "手动断开,不重连"); + return; + } + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + Log.e(TAG, "达到最大重连次数(" + MAX_RECONNECT_ATTEMPTS + "),停止重连"); + notifyError("WebSocket连接失败,已达最大重试次数"); + return; + } + reconnectAttempts++; + long delay = RECONNECT_DELAY_MS * reconnectAttempts; + Log.d(TAG, "将在 " + delay + "ms 后重连 (第" + reconnectAttempts + "次)"); + mainHandler.postDelayed(() -> { + if (!isConnected && !isManualDisconnect) { + doConnect(); + } + }, delay); + } public void disconnect() { + isManualDisconnect = true; + reconnectAttempts = 0; + stopHeartbeat(); if (webSocket != null) { webSocket.close(1000, "User disconnect"); webSocket = null; } isConnected = false; } + + private void startHeartbeat() { + stopHeartbeat(); + heartbeatRunnable = new Runnable() { + @Override + public void run() { + if (isConnected && webSocket != null) { + try { + JSONObject ping = new JSONObject(); + ping.put("type", "ping"); + webSocket.send(ping.toString()); + Log.d(TAG, "发送心跳 ping"); + } catch (JSONException e) { + Log.e(TAG, "心跳发送失败", e); + } + mainHandler.postDelayed(this, HEARTBEAT_INTERVAL_MS); + } + } + }; + mainHandler.postDelayed(heartbeatRunnable, HEARTBEAT_INTERVAL_MS); + } + + private void stopHeartbeat() { + if (heartbeatRunnable != null) { + mainHandler.removeCallbacks(heartbeatRunnable); + heartbeatRunnable = null; + } + } public boolean isConnected() { return isConnected; diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/WebRTCClient.java b/android-app/app/src/main/java/com/example/livestreaming/call/WebRTCClient.java new file mode 100644 index 00000000..bae9ea5e --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/WebRTCClient.java @@ -0,0 +1,729 @@ +package com.example.livestreaming.call; + +import android.content.Context; +import android.util.Log; + +import org.webrtc.AudioSource; +import org.webrtc.AudioTrack; +import org.webrtc.Camera1Enumerator; +import org.webrtc.Camera2Enumerator; +import org.webrtc.CameraEnumerator; +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.SurfaceTextureHelper; +import org.webrtc.SurfaceViewRenderer; +import org.webrtc.VideoCapturer; +import org.webrtc.VideoSource; +import org.webrtc.VideoTrack; + +import java.util.ArrayList; +import java.util.List; + +/** + * WebRTC 客户端 - 处理音视频传输 + */ +public class WebRTCClient { + private static final String TAG = "WebRTCClient"; + + private Context context; + private EglBase eglBase; + private PeerConnectionFactory peerConnectionFactory; + private PeerConnection peerConnection; + + private AudioSource audioSource; + private AudioTrack localAudioTrack; + private VideoSource videoSource; + private VideoTrack localVideoTrack; + private VideoCapturer videoCapturer; + private SurfaceTextureHelper surfaceTextureHelper; + + private SurfaceViewRenderer localRenderer; + private SurfaceViewRenderer remoteRenderer; + + private WebRTCListener listener; + private boolean isVideoCall = false; + private boolean isMuted = false; + private boolean isVideoEnabled = true; + private boolean useFrontCamera = true; + + // ICE 服务器配置 - 可通过 WebRTCConfig 修改 + private static List getIceServers() { + List iceServers = new ArrayList<>(); + + // STUN 服务器(用于获取公网 IP) + for (String stunServer : WebRTCConfig.STUN_SERVERS) { + iceServers.add(PeerConnection.IceServer.builder(stunServer).createIceServer()); + } + + // TURN 服务器(用于 NAT 穿透失败时的中继) + if (WebRTCConfig.TURN_SERVER_URL != null && !WebRTCConfig.TURN_SERVER_URL.isEmpty()) { + // UDP + iceServers.add(PeerConnection.IceServer.builder(WebRTCConfig.TURN_SERVER_URL) + .setUsername(WebRTCConfig.TURN_USERNAME) + .setPassword(WebRTCConfig.TURN_PASSWORD) + .createIceServer()); + + // TCP(备用) + iceServers.add(PeerConnection.IceServer.builder(WebRTCConfig.TURN_SERVER_URL + "?transport=tcp") + .setUsername(WebRTCConfig.TURN_USERNAME) + .setPassword(WebRTCConfig.TURN_PASSWORD) + .createIceServer()); + } + + return iceServers; + } + + public interface WebRTCListener { + void onLocalStream(MediaStream stream); + void onRemoteStream(MediaStream stream); + void onIceCandidate(IceCandidate candidate); + void onIceConnectionChange(PeerConnection.IceConnectionState state); + void onOfferCreated(SessionDescription sdp); + void onAnswerCreated(SessionDescription sdp); + void onError(String error); + } + + public WebRTCClient(Context context) { + this.context = context.getApplicationContext(); + } + + public void setListener(WebRTCListener listener) { + this.listener = listener; + } + + /** + * 初始化 WebRTC + */ + public void initialize(boolean isVideoCall) { + this.isVideoCall = isVideoCall; + Log.d(TAG, "初始化 WebRTC, isVideoCall=" + isVideoCall); + + // 在后台线程初始化,避免阻塞UI + new Thread(() -> { + try { + // 初始化 EglBase + Log.d(TAG, "创建 EglBase..."); + eglBase = EglBase.create(); + Log.d(TAG, "EglBase 创建成功"); + + // 初始化 PeerConnectionFactory + Log.d(TAG, "初始化 PeerConnectionFactory..."); + PeerConnectionFactory.InitializationOptions initOptions = PeerConnectionFactory.InitializationOptions.builder(context) + .setEnableInternalTracer(false) + .createInitializationOptions(); + PeerConnectionFactory.initialize(initOptions); + Log.d(TAG, "PeerConnectionFactory 初始化成功"); + + PeerConnectionFactory.Options options = new PeerConnectionFactory.Options(); + + Log.d(TAG, "创建视频编解码器..."); + DefaultVideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory( + eglBase.getEglBaseContext(), true, true); + DefaultVideoDecoderFactory decoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext()); + Log.d(TAG, "视频编解码器创建成功"); + + Log.d(TAG, "创建 PeerConnectionFactory..."); + peerConnectionFactory = PeerConnectionFactory.builder() + .setOptions(options) + .setVideoEncoderFactory(encoderFactory) + .setVideoDecoderFactory(decoderFactory) + .createPeerConnectionFactory(); + + Log.d(TAG, "PeerConnectionFactory 创建成功"); + } catch (Exception e) { + Log.e(TAG, "WebRTC 初始化异常", e); + if (listener != null) { + listener.onError("WebRTC初始化失败: " + e.getMessage()); + } + } + }).start(); + + // 等待初始化完成(最多5秒) + int waitCount = 0; + while (peerConnectionFactory == null && waitCount < 50) { + try { + Thread.sleep(100); + waitCount++; + } catch (InterruptedException e) { + break; + } + } + + if (peerConnectionFactory == null) { + Log.e(TAG, "WebRTC 初始化超时"); + if (listener != null) { + listener.onError("WebRTC初始化超时"); + } + } else { + Log.d(TAG, "WebRTC 初始化完成,耗时: " + (waitCount * 100) + "ms"); + } + } + + /** + * 设置本地视频渲染器 + */ + public void setLocalRenderer(SurfaceViewRenderer renderer) { + this.localRenderer = renderer; + if (localRenderer != null) { + localRenderer.init(eglBase.getEglBaseContext(), null); + localRenderer.setMirror(true); + localRenderer.setEnableHardwareScaler(true); + } + } + + /** + * 设置远程视频渲染器 + */ + public void setRemoteRenderer(SurfaceViewRenderer renderer) { + this.remoteRenderer = renderer; + if (remoteRenderer != null) { + remoteRenderer.init(eglBase.getEglBaseContext(), null); + remoteRenderer.setMirror(false); + remoteRenderer.setEnableHardwareScaler(true); + } + } + + /** + * 创建本地媒体流 + */ + public void createLocalStream() { + Log.d(TAG, "========== 创建本地媒体流 =========="); + Log.d(TAG, "isVideoCall=" + isVideoCall); + + try { + // 创建音频轨道 + MediaConstraints audioConstraints = new MediaConstraints(); + audioConstraints.mandatory.add(new MediaConstraints.KeyValuePair("googEchoCancellation", "true")); + audioConstraints.mandatory.add(new MediaConstraints.KeyValuePair("googAutoGainControl", "true")); + audioConstraints.mandatory.add(new MediaConstraints.KeyValuePair("googHighpassFilter", "true")); + audioConstraints.mandatory.add(new MediaConstraints.KeyValuePair("googNoiseSuppression", "true")); + + if (peerConnectionFactory != null) { + audioSource = peerConnectionFactory.createAudioSource(audioConstraints); + if (audioSource != null) { + localAudioTrack = peerConnectionFactory.createAudioTrack("ARDAMSa0", audioSource); + if (localAudioTrack != null) { + localAudioTrack.setEnabled(true); + Log.d(TAG, "音频轨道创建成功"); + } else { + Log.w(TAG, "音频轨道创建返回null,继续执行"); + } + } else { + Log.w(TAG, "音频源创建返回null,继续执行"); + } + } else { + Log.e(TAG, "peerConnectionFactory为null!"); + if (listener != null) { + listener.onError("PeerConnectionFactory未初始化"); + } + return; + } + } catch (Exception e) { + Log.e(TAG, "音频轨道创建失败,继续执行", e); + // 不返回,继续尝试创建视频轨道 + } + + // 如果是视频通话,创建视频轨道 + if (isVideoCall) { + Log.d(TAG, "视频通话,开始创建视频轨道"); + try { + createVideoTrack(); + Log.d(TAG, "视频轨道创建结果: localVideoTrack=" + (localVideoTrack != null)); + } catch (Exception e) { + Log.e(TAG, "视频轨道创建失败", e); + if (listener != null) { + listener.onError("视频轨道创建失败: " + e.getMessage()); + } + } + } else { + Log.d(TAG, "语音通话,跳过视频轨道创建"); + } + + Log.d(TAG, "本地媒体流创建完成: audioTrack=" + (localAudioTrack != null) + ", videoTrack=" + (localVideoTrack != null)); + } + + /** + * 创建视频轨道 + */ + private void createVideoTrack() { + Log.d(TAG, "创建视频轨道"); + + videoCapturer = createCameraCapturer(); + if (videoCapturer == null) { + Log.e(TAG, "无法创建摄像头捕获器"); + if (listener != null) { + listener.onError("无法访问摄像头"); + } + return; + } + + surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglBase.getEglBaseContext()); + videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast()); + videoCapturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver()); + + // 启动摄像头捕获 (720p, 30fps) + videoCapturer.startCapture(1280, 720, 30); + + localVideoTrack = peerConnectionFactory.createVideoTrack("ARDAMSv0", videoSource); + localVideoTrack.setEnabled(true); + + // 添加到本地渲染器 + if (localRenderer != null) { + localVideoTrack.addSink(localRenderer); + } + + Log.d(TAG, "视频轨道创建成功"); + } + + /** + * 创建摄像头捕获器 + */ + private VideoCapturer createCameraCapturer() { + CameraEnumerator enumerator; + if (Camera2Enumerator.isSupported(context)) { + enumerator = new Camera2Enumerator(context); + } else { + enumerator = new Camera1Enumerator(true); + } + + String[] deviceNames = enumerator.getDeviceNames(); + + // 优先使用前置摄像头 + for (String deviceName : deviceNames) { + if (useFrontCamera && enumerator.isFrontFacing(deviceName)) { + VideoCapturer capturer = enumerator.createCapturer(deviceName, null); + if (capturer != null) { + Log.d(TAG, "使用前置摄像头: " + deviceName); + return capturer; + } + } + } + + // 如果没有前置摄像头,使用后置 + for (String deviceName : deviceNames) { + if (!enumerator.isFrontFacing(deviceName)) { + VideoCapturer capturer = enumerator.createCapturer(deviceName, null); + if (capturer != null) { + Log.d(TAG, "使用后置摄像头: " + deviceName); + return capturer; + } + } + } + + return null; + } + + /** + * 创建 PeerConnection + */ + public void createPeerConnection() { + Log.d(TAG, "创建 PeerConnection"); + + PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(getIceServers()); + config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; + config.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; + + peerConnection = peerConnectionFactory.createPeerConnection(config, new PeerConnection.Observer() { + @Override + public void onSignalingChange(PeerConnection.SignalingState signalingState) { + Log.d(TAG, "onSignalingChange: " + signalingState); + } + + @Override + public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) { + Log.d(TAG, "========== ICE 连接状态变化 =========="); + Log.d(TAG, "ICE状态: " + iceConnectionState); + // 打印详细的连接信息 + if (peerConnection != null) { + Log.d(TAG, "信令状态: " + peerConnection.signalingState()); + Log.d(TAG, "连接状态: " + peerConnection.connectionState()); + } + if (listener != null) { + listener.onIceConnectionChange(iceConnectionState); + } + } + + @Override + public void onIceConnectionReceivingChange(boolean b) { + Log.d(TAG, "onIceConnectionReceivingChange: " + b); + } + + @Override + public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) { + Log.d(TAG, "onIceGatheringChange: " + iceGatheringState); + } + + @Override + public void onIceCandidate(IceCandidate iceCandidate) { + Log.d(TAG, "========== 生成 ICE Candidate =========="); + Log.d(TAG, "sdpMid: " + iceCandidate.sdpMid); + Log.d(TAG, "sdpMLineIndex: " + iceCandidate.sdpMLineIndex); + Log.d(TAG, "candidate: " + iceCandidate.sdp); + if (listener != null) { + listener.onIceCandidate(iceCandidate); + } + } + + @Override + public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) { + Log.d(TAG, "onIceCandidatesRemoved"); + } + + @Override + public void onAddStream(MediaStream mediaStream) { + Log.d(TAG, "========== 收到远程媒体流 =========="); + Log.d(TAG, "streamId: " + mediaStream.getId()); + Log.d(TAG, "音频轨道数: " + mediaStream.audioTracks.size()); + Log.d(TAG, "视频轨道数: " + mediaStream.videoTracks.size()); + + if (listener != null) { + listener.onRemoteStream(mediaStream); + } + + // 添加远程音频轨道 + if (mediaStream.audioTracks.size() > 0) { + Log.d(TAG, "启用远程音频轨道"); + mediaStream.audioTracks.get(0).setEnabled(true); + } + + // 添加远程视频到渲染器 + if (mediaStream.videoTracks.size() > 0 && remoteRenderer != null) { + Log.d(TAG, "添加远程视频到渲染器"); + mediaStream.videoTracks.get(0).addSink(remoteRenderer); + } + } + + @Override + public void onRemoveStream(MediaStream mediaStream) { + Log.d(TAG, "onRemoveStream: " + mediaStream.getId()); + } + + @Override + public void onDataChannel(DataChannel dataChannel) { + Log.d(TAG, "onDataChannel"); + } + + @Override + public void onRenegotiationNeeded() { + Log.d(TAG, "onRenegotiationNeeded"); + } + + @Override + public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) { + Log.d(TAG, "========== onAddTrack =========="); + if (rtpReceiver.track() != null) { + Log.d(TAG, "轨道类型: " + rtpReceiver.track().kind()); + Log.d(TAG, "轨道ID: " + rtpReceiver.track().id()); + + // 处理远程视频轨道 + if (rtpReceiver.track().kind().equals("video")) { + VideoTrack remoteVideoTrack = (VideoTrack) rtpReceiver.track(); + Log.d(TAG, "收到远程视频轨道,添加到渲染器"); + if (remoteRenderer != null) { + remoteVideoTrack.addSink(remoteRenderer); + Log.d(TAG, "远程视频已添加到渲染器"); + } else { + Log.e(TAG, "remoteRenderer 为空,无法显示远程视频"); + } + } + } + } + }); + + // 添加本地轨道到 PeerConnection + Log.d(TAG, "========== 添加本地轨道 =========="); + Log.d(TAG, "localAudioTrack=" + (localAudioTrack != null) + ", localVideoTrack=" + (localVideoTrack != null)); + + if (localAudioTrack != null) { + peerConnection.addTrack(localAudioTrack); + Log.d(TAG, "已添加本地音频轨道"); + } else { + Log.w(TAG, "本地音频轨道为空,无法添加"); + } + if (localVideoTrack != null) { + peerConnection.addTrack(localVideoTrack); + Log.d(TAG, "已添加本地视频轨道"); + } else { + Log.w(TAG, "本地视频轨道为空,无法添加(如果是视频通话则有问题)"); + } + + Log.d(TAG, "PeerConnection 创建成功, isVideoCall=" + isVideoCall); + } + + /** + * 创建 Offer (主叫方调用) + */ + public void createOffer() { + Log.d(TAG, "创建 Offer"); + + MediaConstraints constraints = new MediaConstraints(); + constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")); + constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", isVideoCall ? "true" : "false")); + + peerConnection.createOffer(new SdpObserver() { + @Override + public void onCreateSuccess(SessionDescription sessionDescription) { + Log.d(TAG, "Offer 创建成功"); + peerConnection.setLocalDescription(new SdpObserver() { + @Override + public void onCreateSuccess(SessionDescription sessionDescription) {} + + @Override + public void onSetSuccess() { + Log.d(TAG, "本地 SDP 设置成功"); + if (listener != null) { + listener.onOfferCreated(sessionDescription); + } + } + + @Override + public void onCreateFailure(String s) {} + + @Override + public void onSetFailure(String s) { + Log.e(TAG, "设置本地 SDP 失败: " + s); + } + }, sessionDescription); + } + + @Override + public void onSetSuccess() {} + + @Override + public void onCreateFailure(String s) { + Log.e(TAG, "创建 Offer 失败: " + s); + if (listener != null) { + listener.onError("创建 Offer 失败: " + s); + } + } + + @Override + public void onSetFailure(String s) {} + }, constraints); + } + + /** + * 创建 Answer (被叫方调用) + */ + public void createAnswer() { + Log.d(TAG, "创建 Answer"); + + MediaConstraints constraints = new MediaConstraints(); + constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")); + constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", isVideoCall ? "true" : "false")); + + peerConnection.createAnswer(new SdpObserver() { + @Override + public void onCreateSuccess(SessionDescription sessionDescription) { + Log.d(TAG, "Answer 创建成功"); + peerConnection.setLocalDescription(new SdpObserver() { + @Override + public void onCreateSuccess(SessionDescription sessionDescription) {} + + @Override + public void onSetSuccess() { + Log.d(TAG, "本地 SDP 设置成功"); + if (listener != null) { + listener.onAnswerCreated(sessionDescription); + } + } + + @Override + public void onCreateFailure(String s) {} + + @Override + public void onSetFailure(String s) { + Log.e(TAG, "设置本地 SDP 失败: " + s); + } + }, sessionDescription); + } + + @Override + public void onSetSuccess() {} + + @Override + public void onCreateFailure(String s) { + Log.e(TAG, "创建 Answer 失败: " + s); + if (listener != null) { + listener.onError("创建 Answer 失败: " + s); + } + } + + @Override + public void onSetFailure(String s) {} + }, constraints); + } + + /** + * 设置远程 SDP + */ + public void setRemoteDescription(SessionDescription sdp) { + Log.d(TAG, "设置远程 SDP, type=" + sdp.type); + + peerConnection.setRemoteDescription(new SdpObserver() { + @Override + public void onCreateSuccess(SessionDescription sessionDescription) {} + + @Override + public void onSetSuccess() { + Log.d(TAG, "远程 SDP 设置成功"); + // 如果是 Offer,需要创建 Answer + if (sdp.type == SessionDescription.Type.OFFER) { + createAnswer(); + } + } + + @Override + public void onCreateFailure(String s) {} + + @Override + public void onSetFailure(String s) { + Log.e(TAG, "设置远程 SDP 失败: " + s); + if (listener != null) { + listener.onError("设置远程 SDP 失败: " + s); + } + } + }, sdp); + } + + /** + * 添加 ICE Candidate + */ + public void addIceCandidate(IceCandidate candidate) { + Log.d(TAG, "========== 添加远程 ICE Candidate =========="); + Log.d(TAG, "sdpMid: " + candidate.sdpMid); + Log.d(TAG, "sdpMLineIndex: " + candidate.sdpMLineIndex); + Log.d(TAG, "candidate: " + candidate.sdp); + if (peerConnection != null) { + boolean result = peerConnection.addIceCandidate(candidate); + Log.d(TAG, "添加结果: " + result); + } else { + Log.e(TAG, "peerConnection 为 null,无法添加 ICE Candidate"); + } + } + + /** + * 切换静音 + */ + public void setMuted(boolean muted) { + this.isMuted = muted; + if (localAudioTrack != null) { + localAudioTrack.setEnabled(!muted); + } + Log.d(TAG, "静音状态: " + muted); + } + + /** + * 切换视频 + */ + public void setVideoEnabled(boolean enabled) { + this.isVideoEnabled = enabled; + if (localVideoTrack != null) { + localVideoTrack.setEnabled(enabled); + } + Log.d(TAG, "视频状态: " + enabled); + } + + /** + * 切换摄像头 + */ + public void switchCamera() { + if (videoCapturer instanceof org.webrtc.CameraVideoCapturer) { + ((org.webrtc.CameraVideoCapturer) videoCapturer).switchCamera(null); + useFrontCamera = !useFrontCamera; + if (localRenderer != null) { + localRenderer.setMirror(useFrontCamera); + } + Log.d(TAG, "切换摄像头, 使用前置: " + useFrontCamera); + } + } + + /** + * 释放资源 + */ + public void release() { + Log.d(TAG, "释放 WebRTC 资源"); + + if (videoCapturer != null) { + try { + videoCapturer.stopCapture(); + } catch (InterruptedException e) { + Log.e(TAG, "停止摄像头捕获失败", e); + } + videoCapturer.dispose(); + videoCapturer = null; + } + + if (surfaceTextureHelper != null) { + surfaceTextureHelper.dispose(); + surfaceTextureHelper = null; + } + + if (localVideoTrack != null) { + localVideoTrack.dispose(); + localVideoTrack = null; + } + + if (videoSource != null) { + videoSource.dispose(); + videoSource = null; + } + + if (localAudioTrack != null) { + localAudioTrack.dispose(); + localAudioTrack = null; + } + + if (audioSource != null) { + audioSource.dispose(); + audioSource = null; + } + + if (peerConnection != null) { + peerConnection.close(); + peerConnection = null; + } + + if (peerConnectionFactory != null) { + peerConnectionFactory.dispose(); + peerConnectionFactory = null; + } + + if (localRenderer != null) { + localRenderer.release(); + } + + if (remoteRenderer != null) { + remoteRenderer.release(); + } + + if (eglBase != null) { + eglBase.release(); + eglBase = null; + } + + Log.d(TAG, "WebRTC 资源释放完成"); + } + + public boolean isMuted() { + return isMuted; + } + + public boolean isVideoEnabled() { + return isVideoEnabled; + } + + public EglBase getEglBase() { + return eglBase; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/WebRTCConfig.java b/android-app/app/src/main/java/com/example/livestreaming/call/WebRTCConfig.java new file mode 100644 index 00000000..306ce122 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/WebRTCConfig.java @@ -0,0 +1,52 @@ +package com.example.livestreaming.call; + +/** + * WebRTC 配置 + * 部署时修改这里的服务器地址 + */ +public class WebRTCConfig { + + // ============ STUN 服务器 ============ + // STUN 用于获取设备的公网IP,帮助建立P2P连接 + public static final String[] STUN_SERVERS = { + "stun:stun.l.google.com:19302", // Google STUN(国内可能不稳定) + "stun:stun.qq.com:3478", // 腾讯 STUN(国内推荐) + "stun:stun.miwifi.com:3478" // 小米 STUN(国内推荐) + }; + + // ============ TURN 服务器 ============ + // TURN 用于在P2P连接失败时进行中继转发 + + // 你的服务器TURN地址 + public static final String TURN_SERVER_URL = "turn:1.15.149.240:3478"; + + // TURN 服务器用户名 + public static final String TURN_USERNAME = "turnuser"; + + // TURN 服务器密码 + public static final String TURN_PASSWORD = "TurnPass123456"; + + // ============ 使用说明 ============ + /* + * 局域网测试: + * - 不需要修改,当前配置即可使用 + * + * 部署到公网服务器: + * 1. 在服务器安装 coturn (TURN服务器) + * 2. 修改上面的配置: + * TURN_SERVER_URL = "turn:你的服务器IP:3478" + * TURN_USERNAME = "你设置的用户名" + * TURN_PASSWORD = "你设置的密码" + * + * 宝塔安装 coturn 步骤: + * 1. SSH执行: yum install -y coturn (CentOS) 或 apt install -y coturn (Ubuntu) + * 2. 编辑 /etc/turnserver.conf: + * listening-port=3478 + * external-ip=你的公网IP + * realm=你的公网IP + * lt-cred-mech + * user=用户名:密码 + * 3. 宝塔放行端口: 3478(TCP/UDP), 49152-65535(UDP) + * 4. 启动: systemctl start coturn + */ +} diff --git a/android-app/app/src/main/res/layout/activity_call.xml b/android-app/app/src/main/res/layout/activity_call.xml index ca8d27b5..7f16c5a0 100644 --- a/android-app/app/src/main/res/layout/activity_call.xml +++ b/android-app/app/src/main/res/layout/activity_call.xml @@ -5,7 +5,7 @@ android:layout_height="match_parent" android:background="#1A1A2E"> - + - - + - - + diff --git a/android-app/gradle.properties b/android-app/gradle.properties index 7f4b982d..9aca9eaf 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -1,6 +1,8 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true +# 使用 Android Studio 自带的 JDK 17 +org.gradle.java.home=C:/Program Files/Android/Android Studio/jbr systemProp.gradle.wrapperUser=myuser systemProp.gradle.wrapperPassword=mypassword \ No newline at end of file