diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebSocketConfig.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebSocketConfig.java index d964cfda..fbcec394 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebSocketConfig.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebSocketConfig.java @@ -1,6 +1,7 @@ package com.zbkj.front.config; import com.zbkj.front.interceptor.WebSocketAuthInterceptor; +import com.zbkj.front.websocket.CallSignalingHandler; import com.zbkj.front.websocket.LiveChatHandler; import com.zbkj.front.websocket.PrivateChatHandler; import org.springframework.context.annotation.Configuration; @@ -25,13 +26,16 @@ public class WebSocketConfig implements WebSocketConfigurer { private final LiveChatHandler liveChatHandler; private final PrivateChatHandler privateChatHandler; + private final CallSignalingHandler callSignalingHandler; private final WebSocketAuthInterceptor webSocketAuthInterceptor; public WebSocketConfig(LiveChatHandler liveChatHandler, PrivateChatHandler privateChatHandler, + CallSignalingHandler callSignalingHandler, WebSocketAuthInterceptor webSocketAuthInterceptor) { this.liveChatHandler = liveChatHandler; this.privateChatHandler = privateChatHandler; + this.callSignalingHandler = callSignalingHandler; this.webSocketAuthInterceptor = webSocketAuthInterceptor; } @@ -48,5 +52,10 @@ public class WebSocketConfig implements WebSocketConfigurer { registry.addHandler(privateChatHandler, "/ws/chat/{conversationId}") .addInterceptors(webSocketAuthInterceptor) .setAllowedOrigins("*"); + + // 通话信令 WebSocket 端点: ws://host:8081/ws/call + // 用于语音/视频通话的信令交换 + registry.addHandler(callSignalingHandler, "/ws/call") + .setAllowedOrigins("*"); } } 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 0f4d9bd3..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 @@ -8,6 +8,7 @@ import com.zbkj.common.result.CommonResult; import com.zbkj.front.request.call.InitiateCallRequest; import com.zbkj.front.response.call.CallRecordResponse; import com.zbkj.front.response.call.InitiateCallResponse; +import com.zbkj.front.websocket.CallSignalingHandler; import com.zbkj.service.service.CallService; import com.zbkj.service.service.UserService; import io.swagger.annotations.Api; @@ -32,6 +33,9 @@ public class CallController { @Autowired private UserService userService; + @Autowired + private CallSignalingHandler callSignalingHandler; + @ApiOperation(value = "发起通话") @PostMapping("/initiate") public CommonResult initiateCall(@RequestBody @Validated InitiateCallRequest request) { @@ -40,6 +44,17 @@ public class CallController { User callee = userService.getById(request.getCalleeId()); if (callee == null) return CommonResult.failed("被叫用户不存在"); CallRecord record = callService.createCall(currentUser.getUid(), request.getCalleeId(), request.getCallType()); + + // 通过WebSocket通知被叫方 + callSignalingHandler.notifyIncomingCall( + record.getCallId(), + currentUser.getUid(), + currentUser.getNickname(), + currentUser.getAvatar(), + request.getCalleeId(), + request.getCallType() + ); + InitiateCallResponse response = new InitiateCallResponse(); response.setCallId(record.getCallId()); response.setCallType(record.getCallType()); @@ -57,6 +72,19 @@ public class CallController { public CommonResult acceptCall(@PathVariable String callId) { User currentUser = userService.getInfo(); if (currentUser == null) return CommonResult.failed("请先登录"); + + System.out.println("[CallController] 接听通话API: callId=" + callId + ", userId=" + currentUser.getUid()); + + // 获取通话记录,找到主叫方 + CallRecord record = callService.getByCallId(callId); + if (record != null) { + System.out.println("[CallController] 通话记录: callerId=" + record.getCallerId() + ", status=" + record.getStatus()); + // 通过WebSocket通知主叫方 + callSignalingHandler.notifyCallAccepted(callId, record.getCallerId()); + } else { + System.out.println("[CallController] 通话记录不存在: callId=" + callId); + } + return CommonResult.success(callService.acceptCall(callId, currentUser.getUid())); } @@ -65,6 +93,14 @@ public class CallController { public CommonResult rejectCall(@PathVariable String callId) { User currentUser = userService.getInfo(); if (currentUser == null) return CommonResult.failed("请先登录"); + + // 获取通话记录,找到主叫方 + CallRecord record = callService.getByCallId(callId); + if (record != null) { + // 通过WebSocket通知主叫方 + callSignalingHandler.notifyCallRejected(callId, record.getCallerId()); + } + return CommonResult.success(callService.rejectCall(callId, currentUser.getUid())); } diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ConversationController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ConversationController.java index 94dea5e8..5db2b132 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ConversationController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ConversationController.java @@ -45,6 +45,17 @@ public class ConversationController { return CommonResult.success(conversationService.getConversationList(userId)); } + /** + * 获取单个会话详情 + */ + @ApiOperation(value = "获取单个会话详情") + @ApiImplicitParam(name = "id", value = "会话ID", required = true) + @GetMapping("/{id}") + public CommonResult getConversationDetail(@PathVariable Long id) { + Integer userId = userService.getUserIdException(); + return CommonResult.success(conversationService.getConversationDetail(id, userId)); + } + /** * 搜索会话 */ 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 64500e0e..9086d9b7 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 @@ -105,6 +105,7 @@ public class CallSignalingHandler extends TextWebSocketHandler { // 关闭旧连接 WebSocketSession oldSession = userCallSessions.get(userId); if (oldSession != null && oldSession.isOpen() && !oldSession.getId().equals(session.getId())) { + logger.info("[CallSignaling] 关闭旧连接: userId={}, oldSessionId={}", userId, oldSession.getId()); try { oldSession.close(); } catch (Exception ignored) {} @@ -117,7 +118,8 @@ public class CallSignalingHandler extends TextWebSocketHandler { response.put("type", "registered"); response.put("userId", userId); session.sendMessage(new TextMessage(response.toString())); - logger.info("[CallSignaling] 用户注册: userId={}", userId); + logger.info("[CallSignaling] 用户注册成功: userId={}, sessionId={}, 当前在线用户数={}", + userId, session.getId(), userCallSessions.size()); } private void handleCallRequest(WebSocketSession session, JsonNode json) throws IOException { @@ -179,30 +181,50 @@ public class CallSignalingHandler extends TextWebSocketHandler { Integer userId = sessionUserMap.get(session.getId()); String callId = json.has("callId") ? json.get("callId").asText() : null; + logger.info("[CallSignaling] 处理接听请求: callId={}, userId={}", callId, userId); + if (userId == null || callId == null) { sendError(session, "参数不完整"); return; } try { - callService.acceptCall(callId, userId); + // 先获取通话记录,用于后续通知 + CallRecord record = callService.getByCallId(callId); + if (record == null) { + sendError(session, "通话记录不存在"); + return; + } + + logger.info("[CallSignaling] 通话记录: callId={}, status={}, callerId={}", + callId, record.getStatus(), record.getCallerId()); + + // 更新通话状态 + Boolean accepted = callService.acceptCall(callId, userId); + logger.info("[CallSignaling] acceptCall结果: {}", accepted); + joinCallSession(callId, session); sessionCallMap.put(session.getId(), callId); // 通知主叫方 - CallRecord record = callService.getByCallId(callId); - if (record != null) { - WebSocketSession callerSession = userCallSessions.get(record.getCallerId()); - if (callerSession != null && callerSession.isOpen()) { - ObjectNode notify = objectMapper.createObjectNode(); - notify.put("type", "call_accepted"); - notify.put("callId", callId); - callerSession.sendMessage(new TextMessage(notify.toString())); - } + Integer callerId = record.getCallerId(); + WebSocketSession callerSession = userCallSessions.get(callerId); + logger.info("[CallSignaling] 查找主叫方会话: callerId={}, session存在={}, session打开={}", + callerId, callerSession != null, callerSession != null && callerSession.isOpen()); + + if (callerSession != null && callerSession.isOpen()) { + ObjectNode notify = objectMapper.createObjectNode(); + notify.put("type", "call_accepted"); + notify.put("callId", callId); + callerSession.sendMessage(new TextMessage(notify.toString())); + logger.info("[CallSignaling] 已通知主叫方接听: callId={}, callerId={}", callId, callerId); + } else { + logger.warn("[CallSignaling] 主叫方WebSocket未连接: callerId={}", callerId); } - logger.info("[CallSignaling] 接听通话: callId={}, userId={}", callId, userId); + logger.info("[CallSignaling] 接听通话完成: callId={}, userId={}", callId, userId); } catch (Exception e) { + logger.error("[CallSignaling] 接听通话异常: callId={}, error={}", callId, e.getMessage(), e); sendError(session, e.getMessage()); } } @@ -445,4 +467,84 @@ public class CallSignalingHandler extends TextWebSocketHandler { error.put("message", message); session.sendMessage(new TextMessage(error.toString())); } + + /** + * 通知被叫方有来电(供REST API调用) + */ + public void notifyIncomingCall(String callId, Integer callerId, String callerName, String callerAvatar, + Integer calleeId, String callType) { + try { + // 记录通话创建时间 + callCreateTime.put(callId, System.currentTimeMillis()); + + // 通知被叫方 + WebSocketSession calleeSession = userCallSessions.get(calleeId); + if (calleeSession != null && calleeSession.isOpen()) { + ObjectNode incoming = objectMapper.createObjectNode(); + incoming.put("type", "incoming_call"); + incoming.put("callId", callId); + incoming.put("callerId", callerId); + incoming.put("callerName", callerName != null ? callerName : "用户" + callerId); + incoming.put("callerAvatar", callerAvatar != null ? callerAvatar : ""); + incoming.put("callType", callType); + calleeSession.sendMessage(new TextMessage(incoming.toString())); + logger.info("[CallSignaling] 通知来电: callId={}, callee={}", callId, calleeId); + } else { + logger.warn("[CallSignaling] 被叫方未在线: calleeId={}", calleeId); + } + } catch (Exception e) { + logger.error("[CallSignaling] 通知来电异常: callId={}", callId, e); + } + } + + /** + * 检查用户是否在线 + */ + public boolean isUserOnline(Integer userId) { + WebSocketSession session = userCallSessions.get(userId); + return session != null && session.isOpen(); + } + + /** + * 通知主叫方通话已被接听(供REST API调用) + */ + public void notifyCallAccepted(String callId, Integer callerId) { + logger.info("[CallSignaling] REST API调用notifyCallAccepted: callId={}, callerId={}, 当前在线用户={}", + callId, callerId, userCallSessions.keySet()); + try { + WebSocketSession callerSession = userCallSessions.get(callerId); + if (callerSession != null && callerSession.isOpen()) { + ObjectNode notify = objectMapper.createObjectNode(); + notify.put("type", "call_accepted"); + notify.put("callId", callId); + callerSession.sendMessage(new TextMessage(notify.toString())); + logger.info("[CallSignaling] 通知接听成功: callId={}, caller={}", callId, callerId); + } else { + logger.warn("[CallSignaling] 主叫方未在线: callerId={}, session存在={}", + callerId, callerSession != null); + } + } catch (Exception e) { + logger.error("[CallSignaling] 通知接听异常: callId={}", callId, e); + } + } + + /** + * 通知主叫方通话已被拒绝(供REST API调用) + */ + public void notifyCallRejected(String callId, Integer callerId) { + try { + WebSocketSession callerSession = userCallSessions.get(callerId); + if (callerSession != null && callerSession.isOpen()) { + ObjectNode notify = objectMapper.createObjectNode(); + notify.put("type", "call_rejected"); + notify.put("callId", callId); + callerSession.sendMessage(new TextMessage(notify.toString())); + logger.info("[CallSignaling] 通知拒绝: callId={}, caller={}", callId, callerId); + } else { + logger.warn("[CallSignaling] 主叫方未在线: callerId={}", callerId); + } + } catch (Exception e) { + logger.error("[CallSignaling] 通知拒绝异常: callId={}", callId, e); + } + } } diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/ConversationService.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/ConversationService.java index 6952da57..e6392110 100644 --- a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/ConversationService.java +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/ConversationService.java @@ -18,6 +18,11 @@ public interface ConversationService extends IService { */ List getConversationList(Integer userId); + /** + * 获取单个会话详情 + */ + ConversationResponse getConversationDetail(Long conversationId, Integer userId); + /** * 搜索会话 */ @@ -48,6 +53,16 @@ public interface ConversationService extends IService { */ Boolean deleteMessage(Long messageId, Integer userId); + /** + * 撤回消息 + */ + Boolean recallMessage(Long messageId, Integer userId); + + /** + * 获取单条消息详情 + */ + ChatMessageResponse getMessageById(Long messageId); + /** * 获取或创建与指定用户的会话 */ diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/CallServiceImpl.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/CallServiceImpl.java index 84d2998b..118e6b85 100644 --- a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/CallServiceImpl.java +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/CallServiceImpl.java @@ -101,8 +101,16 @@ public class CallServiceImpl extends ServiceImpl impl CallRecord record = getByCallId(callId); if (record == null) throw new CrmebException("通话记录不存在"); if (!record.getCalleeId().equals(userId)) throw new CrmebException("无权操作此通话"); + + // 记录当前状态用于调试 + logger.info("[Call] 接听通话: callId={}, 当前状态={}, userId={}", callId, record.getStatus(), userId); + + // 只有在 calling 或 ringing 状态才能接听 + // 如果已经是其他状态(如 missed, ended),说明通话已经结束 if (!STATUS_CALLING.equals(record.getStatus()) && !STATUS_RINGING.equals(record.getStatus())) { - throw new CrmebException("通话状态不正确"); + logger.warn("[Call] 通话状态不正确,无法接听: callId={}, status={}", callId, record.getStatus()); + // 返回 false 而不是抛异常,让前端可以处理 + return false; } Date now = new Date(); diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/ConversationServiceImpl.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/ConversationServiceImpl.java index 4c748a3c..f4be294c 100644 --- a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/ConversationServiceImpl.java +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/ConversationServiceImpl.java @@ -47,6 +47,19 @@ public class ConversationServiceImpl extends ServiceImpl searchConversations(Integer userId, String keyword) { // 先获取用户的所有会话 @@ -262,35 +275,42 @@ public class ConversationServiceImpl extends ServiceImpl convertToResponseList(List conversations, Integer userId) { List result = new ArrayList<>(); for (Conversation conv : conversations) { - ConversationResponse response = new ConversationResponse(); - response.setId(String.valueOf(conv.getId())); - response.setLastMessage(conv.getLastMessage()); - response.setTimeText(formatTimeText(conv.getLastMessageTime())); - // 确定对方用户ID - Integer otherUserId = conv.getUser1Id().equals(userId) ? conv.getUser2Id() : conv.getUser1Id(); - response.setOtherUserId(otherUserId); - // 获取未读数和静音状态 - if (conv.getUser1Id().equals(userId)) { - response.setUnreadCount(conv.getUser1UnreadCount()); - response.setMuted(conv.getUser1Muted()); - } else { - response.setUnreadCount(conv.getUser2UnreadCount()); - response.setMuted(conv.getUser2Muted()); - } - // 获取对方用户信息 - User otherUser = userService.getById(otherUserId); - if (otherUser != null) { - response.setTitle(otherUser.getNickname()); - response.setAvatarUrl(otherUser.getAvatar()); - } else { - response.setTitle("未知用户"); - response.setAvatarUrl(""); - } - result.add(response); + result.add(convertToResponse(conv, userId)); } return result; } + /** + * 转换单个会话为响应对象 + */ + private ConversationResponse convertToResponse(Conversation conv, Integer userId) { + ConversationResponse response = new ConversationResponse(); + response.setId(String.valueOf(conv.getId())); + response.setLastMessage(conv.getLastMessage()); + response.setTimeText(formatTimeText(conv.getLastMessageTime())); + // 确定对方用户ID + Integer otherUserId = conv.getUser1Id().equals(userId) ? conv.getUser2Id() : conv.getUser1Id(); + response.setOtherUserId(otherUserId); + // 获取未读数和静音状态 + if (conv.getUser1Id().equals(userId)) { + response.setUnreadCount(conv.getUser1UnreadCount()); + response.setMuted(conv.getUser1Muted()); + } else { + response.setUnreadCount(conv.getUser2UnreadCount()); + response.setMuted(conv.getUser2Muted()); + } + // 获取对方用户信息 + User otherUser = userService.getById(otherUserId); + if (otherUser != null) { + response.setTitle(otherUser.getNickname()); + response.setAvatarUrl(otherUser.getAvatar()); + } else { + response.setTitle("未知用户"); + response.setAvatarUrl(""); + } + return response; + } + /** * 转换消息列表为响应对象列表 */ @@ -374,6 +394,41 @@ public class ConversationServiceImpl extends ServiceImpl 2) { + throw new CrmebException("消息发送超过2分钟,无法撤回"); + } + // 标记消息为已撤回 + LambdaUpdateWrapper uw = new LambdaUpdateWrapper<>(); + uw.eq(PrivateMessage::getId, messageId) + .set(PrivateMessage::getIsRecalled, true) + .set(PrivateMessage::getContent, "[消息已撤回]"); + return privateMessageDao.update(null, uw) > 0; + } + + @Override + public ChatMessageResponse getMessageById(Long messageId) { + PrivateMessage message = privateMessageDao.selectById(messageId); + if (message == null) { + return null; + } + return convertMessageToResponse(message); + } + @Override @Transactional(rollbackFor = Exception.class) public PrivateMessage sendMessage(Long conversationId, Integer senderId, String messageType, String content, String mediaUrl) { diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 3099e949..6eaab2d1 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -8,6 +8,10 @@ android:maxSdkVersion="32" /> + + + + + + + + + + + diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java index b27cc09c..3e432df7 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java @@ -77,6 +77,14 @@ public class ConversationActivity extends AppCompatActivity { intent.putExtra(EXTRA_CONVERSATION_TITLE, title); context.startActivity(intent); } + + public static void start(Context context, String conversationId, String title, int otherUserId) { + Intent intent = new Intent(context, ConversationActivity.class); + intent.putExtra(EXTRA_CONVERSATION_ID, conversationId); + intent.putExtra(EXTRA_CONVERSATION_TITLE, title); + intent.putExtra("other_user_id", otherUserId); + context.startActivity(intent); + } @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -687,6 +695,159 @@ public class ConversationActivity extends AppCompatActivity { binding.messageInput.setOnFocusChangeListener((v, hasFocus) -> { if (hasFocus) scrollToBottom(); }); + + // 设置通话按钮点击事件 + setupCallButtons(); + } + + /** + * 设置通话按钮 + */ + private void setupCallButtons() { + // 语音通话按钮 + binding.voiceCallButton.setOnClickListener(new DebounceClickListener() { + @Override + public void onDebouncedClick(View v) { + initiateCall("voice"); + } + }); + + // 视频通话按钮 + binding.videoCallButton.setOnClickListener(new DebounceClickListener() { + @Override + public void onDebouncedClick(View v) { + initiateCall("video"); + } + }); + } + + /** + * 发起通话 + */ + private void initiateCall(String callType) { + if (!AuthHelper.requireLoginWithToast(this, "发起通话需要登录")) { + return; + } + + // 获取对方用户ID(从会话中解析) + int otherUserId = getOtherUserIdFromConversation(); + if (otherUserId <= 0) { + // 如果无法从Intent获取,尝试从服务器获取 + fetchOtherUserIdFromServer(callType); + return; + } + + startCallWithUserId(otherUserId, callType); + } + + /** + * 使用指定的用户ID发起通话 + */ + private void startCallWithUserId(int otherUserId, String callType) { + String callTypeText = "voice".equals(callType) ? "语音" : "视频"; + Log.d(TAG, "发起" + callTypeText + "通话,对方用户ID: " + otherUserId); + + // 使用CallManager发起通话 + com.example.livestreaming.call.CallManager callManager = + com.example.livestreaming.call.CallManager.getInstance(this); + + // 先连接信令服务器 + if (currentUserId != null && !currentUserId.isEmpty()) { + try { + int myUserId = (int) Double.parseDouble(currentUserId); + callManager.connect(myUserId); + } catch (NumberFormatException e) { + Log.e(TAG, "解析用户ID失败", e); + } + } + + // 发起通话 + callManager.initiateCall(otherUserId, callType, + new com.example.livestreaming.call.CallManager.CallCallback() { + @Override + public void onSuccess(com.example.livestreaming.call.InitiateCallResponse response) { + Log.d(TAG, "通话发起成功: " + response.getCallId()); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + Snackbar.make(binding.getRoot(), "呼叫失败: " + error, Snackbar.LENGTH_SHORT).show(); + }); + } + }); + } + + /** + * 从会话信息中获取对方用户ID + */ + private int getOtherUserIdFromConversation() { + // 尝试从Intent中获取 + int otherUserId = getIntent().getIntExtra("other_user_id", 0); + if (otherUserId > 0) { + Log.d(TAG, "从Intent获取到对方用户ID: " + otherUserId); + return otherUserId; + } + + // 如果Intent中没有,尝试从服务器获取会话详情 + Log.w(TAG, "Intent中没有other_user_id,需要从服务器获取"); + return 0; + } + + /** + * 从服务器获取会话详情以获取对方用户ID + */ + private void fetchOtherUserIdFromServer(String callType) { + String token = AuthStore.getToken(this); + if (token == null || conversationId == null) { + Snackbar.make(binding.getRoot(), "无法获取对方用户信息", Snackbar.LENGTH_SHORT).show(); + return; + } + + String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + conversationId; + Log.d(TAG, "获取会话详情: " + url); + + Request request = new Request.Builder() + .url(url) + .addHeader("Authori-zation", token) + .get() + .build(); + + httpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "获取会话详情失败", e); + runOnUiThread(() -> Snackbar.make(binding.getRoot(), "无法获取对方用户信息", Snackbar.LENGTH_SHORT).show()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String body = response.body() != null ? response.body().string() : ""; + Log.d(TAG, "会话详情响应: " + body); + runOnUiThread(() -> { + try { + JSONObject json = new JSONObject(body); + if (json.optInt("code", -1) == 200) { + JSONObject data = json.optJSONObject("data"); + if (data != null) { + int otherUserId = data.optInt("otherUserId", 0); + if (otherUserId > 0) { + Log.d(TAG, "从服务器获取到对方用户ID: " + otherUserId); + startCallWithUserId(otherUserId, callType); + } else { + Snackbar.make(binding.getRoot(), "无法获取对方用户信息", Snackbar.LENGTH_SHORT).show(); + } + } + } else { + Snackbar.make(binding.getRoot(), "无法获取对方用户信息", Snackbar.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, "解析会话详情失败", e); + Snackbar.make(binding.getRoot(), "无法获取对方用户信息", Snackbar.LENGTH_SHORT).show(); + } + }); + } + }); } /** diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationItem.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationItem.java index 7032a306..ce8bfdd9 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationItem.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationItem.java @@ -10,6 +10,8 @@ public class ConversationItem { private final String timeText; private final int unreadCount; private final boolean muted; + private int otherUserId; + private String avatarUrl; public ConversationItem(String id, String title, String lastMessage, String timeText, int unreadCount, boolean muted) { this.id = id; @@ -44,6 +46,22 @@ public class ConversationItem { return muted; } + public int getOtherUserId() { + return otherUserId; + } + + public void setOtherUserId(int otherUserId) { + this.otherUserId = otherUserId; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java b/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java index 40eba177..22d2e722 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java +++ b/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java @@ -1,6 +1,10 @@ package com.example.livestreaming; import android.app.Application; +import android.util.Log; + +import com.example.livestreaming.call.CallManager; +import com.example.livestreaming.net.AuthStore; /** * 自定义Application类,用于初始化各种组件 @@ -8,9 +12,13 @@ import android.app.Application; */ public class LiveStreamingApplication extends Application { + private static final String TAG = "LiveStreamingApp"; + private static LiveStreamingApplication instance; + @Override public void onCreate() { super.onCreate(); + instance = this; // 初始化LeakCanary内存泄漏检测(仅在debug版本中生效) // LeakCanary会自动在debug版本中初始化,无需手动调用 @@ -20,5 +28,50 @@ public class LiveStreamingApplication extends Application { // 初始化通知渠道 LocalNotificationManager.createNotificationChannel(this); + + // 如果用户已登录,连接通话信令服务器 + connectCallSignalingIfLoggedIn(); + } + + public static LiveStreamingApplication getInstance() { + return instance; + } + + /** + * 如果用户已登录,连接通话信令服务器 + */ + public void connectCallSignalingIfLoggedIn() { + String userId = AuthStore.getUserId(this); + String token = AuthStore.getToken(this); + + if (token != null && !token.isEmpty() && userId != null && !userId.isEmpty()) { + try { + int uid = (int) Double.parseDouble(userId); + if (uid > 0) { + Log.d(TAG, "用户已登录,连接通话信令服务器,userId: " + uid); + CallManager.getInstance(this).connect(uid); + } + } catch (NumberFormatException e) { + Log.e(TAG, "解析用户ID失败: " + userId, e); + } + } else { + Log.d(TAG, "用户未登录,不连接通话信令服务器"); + } + } + + /** + * 用户登录后调用,连接通话信令服务器 + */ + public void onUserLoggedIn(int userId) { + Log.d(TAG, "用户登录成功,连接通话信令服务器,userId: " + userId); + CallManager.getInstance(this).connect(userId); + } + + /** + * 用户登出后调用,断开通话信令服务器 + */ + public void onUserLoggedOut() { + Log.d(TAG, "用户登出,断开通话信令服务器"); + CallManager.getInstance(this).disconnect(); } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java b/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java index 3bb082c6..44c00a7c 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java @@ -149,6 +149,19 @@ public class LoginActivity extends AppCompatActivity { prefs.edit().putString("profile_phone", loginData.getPhone()).apply(); } + // 连接通话信令服务器 + if (!TextUtils.isEmpty(uid)) { + try { + int userId = (int) Double.parseDouble(uid); + if (userId > 0) { + LiveStreamingApplication app = (LiveStreamingApplication) getApplication(); + app.onUserLoggedIn(userId); + } + } catch (NumberFormatException e) { + android.util.Log.e("LoginActivity", "解析用户ID失败", e); + } + } + // 登录成功 Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show(); diff --git a/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java index 5cdf36c7..03418731 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java @@ -137,11 +137,12 @@ public class MessagesActivity extends AppCompatActivity { conversationsAdapter = new ConversationsAdapter(item -> { if (item == null) return; try { - // 启动会话页面,传递未读数量 + // 启动会话页面,传递未读数量和对方用户ID Intent intent = new Intent(this, ConversationActivity.class); intent.putExtra("extra_conversation_id", item.getId()); intent.putExtra("extra_conversation_title", item.getTitle()); intent.putExtra("extra_unread_count", item.getUnreadCount()); + intent.putExtra("other_user_id", item.getOtherUserId()); startActivity(intent); // 用户点击会话时,减少该会话的未读数量 @@ -231,8 +232,11 @@ public class MessagesActivity extends AppCompatActivity { int unreadCount = item.optInt("unreadCount", 0); boolean isMuted = item.optBoolean("muted", item.optBoolean("isMuted", false)); String avatarUrl = item.optString("avatarUrl", item.optString("otherUserAvatar", "")); + int otherUserId = item.optInt("otherUserId", 0); ConversationItem convItem = new ConversationItem(id, title, lastMessage, timeText, unreadCount, isMuted); + convItem.setOtherUserId(otherUserId); + convItem.setAvatarUrl(avatarUrl); allConversations.add(convItem); } catch (Exception e) { Log.e(TAG, "解析会话项失败", e); diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallActivity.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallActivity.java new file mode 100644 index 00000000..62d88e5f --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallActivity.java @@ -0,0 +1,314 @@ +package com.example.livestreaming.call; + +import android.media.AudioManager; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.WindowManager; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +import com.bumptech.glide.Glide; +import com.example.livestreaming.R; + +/** + * 通话界面 + */ +public class CallActivity extends AppCompatActivity implements CallManager.CallStateListener { + + private ImageView ivBackgroundAvatar; + private ImageView ivAvatar; + private TextView tvUserName; + private TextView tvCallStatus; + private TextView tvCallDuration; + private TextView tvCallType; + private ImageButton btnMinimize; + private ImageButton btnMute; + private ImageButton btnSpeaker; + private ImageButton btnVideo; + private ImageButton btnHangup; + private ImageButton btnSwitchCamera; + private LinearLayout layoutCallControls; + private LinearLayout layoutVideoToggle; + + private CallManager callManager; + private AudioManager audioManager; + private Handler handler; + + private String callId; + private String callType; + private boolean isCaller; + private int otherUserId; + private String otherUserName; + private String otherUserAvatar; + + private boolean isMuted = false; + private boolean isSpeakerOn = false; + private boolean isVideoEnabled = true; + private boolean isConnected = false; + private long callStartTime = 0; + + private Runnable durationRunnable = new Runnable() { + @Override + public void run() { + if (isConnected && callStartTime > 0) { + long duration = (System.currentTimeMillis() - callStartTime) / 1000; + int minutes = (int) (duration / 60); + int seconds = (int) (duration % 60); + tvCallDuration.setText(String.format("%02d:%02d", minutes, seconds)); + handler.postDelayed(this, 1000); + } + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // 保持屏幕常亮,显示在锁屏上方 + getWindow().addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED | + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ); + + setContentView(R.layout.activity_call); + + initViews(); + initData(); + initCallManager(); + setupListeners(); + updateUI(); + } + + private void initViews() { + ivBackgroundAvatar = findViewById(R.id.ivBackgroundAvatar); + ivAvatar = findViewById(R.id.ivAvatar); + tvUserName = findViewById(R.id.tvUserName); + tvCallStatus = findViewById(R.id.tvCallStatus); + tvCallDuration = findViewById(R.id.tvCallDuration); + tvCallType = findViewById(R.id.tvCallType); + btnMinimize = findViewById(R.id.btnMinimize); + btnMute = findViewById(R.id.btnMute); + btnSpeaker = findViewById(R.id.btnSpeaker); + btnVideo = findViewById(R.id.btnVideo); + btnHangup = findViewById(R.id.btnHangup); + btnSwitchCamera = findViewById(R.id.btnSwitchCamera); + layoutCallControls = findViewById(R.id.layoutCallControls); + layoutVideoToggle = findViewById(R.id.layoutVideoToggle); + + handler = new Handler(Looper.getMainLooper()); + audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + } + + private void initData() { + callId = getIntent().getStringExtra("callId"); + callType = getIntent().getStringExtra("callType"); + isCaller = getIntent().getBooleanExtra("isCaller", true); + otherUserId = getIntent().getIntExtra("otherUserId", 0); + otherUserName = getIntent().getStringExtra("otherUserName"); + otherUserAvatar = getIntent().getStringExtra("otherUserAvatar"); + + // 如果是被叫方接听,直接进入通话状态 + boolean alreadyConnected = getIntent().getBooleanExtra("isConnected", false); + if (alreadyConnected) { + android.util.Log.d("CallActivity", "被叫方接听,直接进入通话状态"); + isConnected = true; + callStartTime = System.currentTimeMillis(); + } + } + + private void initCallManager() { + callManager = CallManager.getInstance(this); + callManager.setStateListener(this); + + // 确保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); + callManager.connect(uid); + } + } catch (NumberFormatException e) { + android.util.Log.e("CallActivity", "解析用户ID失败", e); + } + } + } + + private void setupListeners() { + btnMinimize.setOnClickListener(v -> { + // 最小化通话(后台运行) + moveTaskToBack(true); + }); + + btnMute.setOnClickListener(v -> toggleMute()); + btnSpeaker.setOnClickListener(v -> toggleSpeaker()); + btnVideo.setOnClickListener(v -> toggleVideo()); + btnSwitchCamera.setOnClickListener(v -> switchCamera()); + + btnHangup.setOnClickListener(v -> { + if (isConnected) { + callManager.endCall(callId, "user_hangup"); + } else if (isCaller) { + callManager.cancelCall(callId); + } else { + callManager.rejectCall(callId); + } + finish(); + }); + } + + private void updateUI() { + // 设置用户信息 + tvUserName.setText(otherUserName != null ? otherUserName : "用户" + otherUserId); + + if (otherUserAvatar != null && !otherUserAvatar.isEmpty()) { + Glide.with(this).load(otherUserAvatar).into(ivAvatar); + Glide.with(this).load(otherUserAvatar).into(ivBackgroundAvatar); + } + + // 设置通话类型 + boolean isVideo = "video".equals(callType); + tvCallType.setText(isVideo ? "视频通话" : "语音通话"); + layoutVideoToggle.setVisibility(isVideo ? View.VISIBLE : View.GONE); + btnSwitchCamera.setVisibility(isVideo ? View.VISIBLE : View.GONE); + + // 根据连接状态设置界面 + 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 { + tvCallStatus.setText("正在连接..."); + } + } + } + + private void toggleMute() { + 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: 实际静音控制 + Toast.makeText(this, isMuted ? "已静音" : "已取消静音", Toast.LENGTH_SHORT).show(); + } + + private void toggleSpeaker() { + isSpeakerOn = !isSpeakerOn; + audioManager.setSpeakerphoneOn(isSpeakerOn); + btnSpeaker.setBackgroundResource(isSpeakerOn ? R.drawable.bg_call_button_active : R.drawable.bg_call_button); + Toast.makeText(this, isSpeakerOn ? "已开启免提" : "已关闭免提", Toast.LENGTH_SHORT).show(); + } + + private void toggleVideo() { + isVideoEnabled = !isVideoEnabled; + btnVideo.setBackgroundResource(isVideoEnabled ? R.drawable.bg_call_button : R.drawable.bg_call_button_active); + // TODO: 实际视频控制 + Toast.makeText(this, isVideoEnabled ? "已开启摄像头" : "已关闭摄像头", Toast.LENGTH_SHORT).show(); + } + + private void switchCamera() { + // TODO: 切换前后摄像头 + Toast.makeText(this, "切换摄像头", Toast.LENGTH_SHORT).show(); + } + + private void onCallConnected() { + android.util.Log.d("CallActivity", "onCallConnected() 开始执行"); + isConnected = true; + callStartTime = System.currentTimeMillis(); + + tvCallStatus.setVisibility(View.GONE); + tvCallDuration.setVisibility(View.VISIBLE); + layoutCallControls.setVisibility(View.VISIBLE); + + handler.post(durationRunnable); + android.util.Log.d("CallActivity", "onCallConnected() 执行完成,通话已连接"); + Toast.makeText(this, "通话已接通", Toast.LENGTH_SHORT).show(); + } + + // CallStateListener 实现 + @Override + public void onCallStateChanged(String state, String callId) { + // 状态变化处理 + } + + @Override + public void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType) { + // 来电处理(在IncomingCallActivity中处理) + } + + @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(); + }); + } + + @Override + public void onCallEnded(String callId, String reason) { + runOnUiThread(() -> { + String message; + switch (reason) { + case "rejected": + message = "对方已拒绝"; + break; + case "cancelled": + message = "通话已取消"; + break; + case "timeout": + message = "对方无应答"; + break; + case "busy": + message = "对方忙线中"; + break; + case "peer_disconnect": + message = "对方已断开"; + break; + default: + message = "通话已结束"; + } + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + finish(); + }); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + Toast.makeText(this, "通话错误: " + error, Toast.LENGTH_SHORT).show(); + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + handler.removeCallbacks(durationRunnable); + if (callManager != null) { + callManager.setStateListener(null); + } + } + + @Override + public void onBackPressed() { + // 禁止返回键退出,需要点击挂断 + moveTaskToBack(true); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallApiResponse.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallApiResponse.java new file mode 100644 index 00000000..51d47259 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallApiResponse.java @@ -0,0 +1,38 @@ +package com.example.livestreaming.call; + +/** + * 通话API响应包装类 + */ +public class CallApiResponse { + private int code; + private String message; + private T data; + + public boolean isSuccess() { + return code == 200; + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallApiService.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallApiService.java new file mode 100644 index 00000000..ba72570d --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallApiService.java @@ -0,0 +1,76 @@ +package com.example.livestreaming.call; + +import java.util.List; +import java.util.Map; + +import retrofit2.Call; +import retrofit2.http.*; + +/** + * 通话相关API接口 + */ +public interface CallApiService { + + /** + * 发起通话 + */ + @POST("api/front/call/initiate") + Call> initiateCall(@Body InitiateCallRequest request); + + /** + * 接听通话 + */ + @POST("api/front/call/accept/{callId}") + Call> acceptCall(@Path("callId") String callId); + + /** + * 拒绝通话 + */ + @POST("api/front/call/reject/{callId}") + Call> rejectCall(@Path("callId") String callId); + + /** + * 取消通话 + */ + @POST("api/front/call/cancel/{callId}") + Call> cancelCall(@Path("callId") String callId); + + /** + * 结束通话 + */ + @POST("api/front/call/end/{callId}") + Call> endCall(@Path("callId") String callId, @Query("endReason") String endReason); + + /** + * 获取通话记录 + */ + @GET("api/front/call/history") + Call> getCallHistory( + @Query("page") int page, + @Query("limit") int limit + ); + + /** + * 删除通话记录 + */ + @DELETE("api/front/call/record/{recordId}") + Call> deleteCallRecord(@Path("recordId") long recordId); + + /** + * 获取未接来电数量 + */ + @GET("api/front/call/missed/count") + Call> getMissedCallCount(); + + /** + * 获取通话状态 + */ + @GET("api/front/call/status") + Call>> getCallStatus(); + + /** + * 获取通话详情 + */ + @GET("api/front/call/detail/{callId}") + Call> getCallDetail(@Path("callId") String callId); +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryActivity.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryActivity.java new file mode 100644 index 00000000..61dcb6ae --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryActivity.java @@ -0,0 +1,216 @@ +package com.example.livestreaming.call; + +import android.os.Bundle; +import android.widget.ImageButton; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.example.livestreaming.R; +import com.example.livestreaming.net.ApiClient; +import com.example.livestreaming.net.AuthStore; + +import java.util.List; + +import okhttp3.OkHttpClient; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +/** + * 通话记录列表界面 + */ +public class CallHistoryActivity extends AppCompatActivity { + + private ImageButton btnBack; + private TextView tvMissedCount; + private SwipeRefreshLayout swipeRefresh; + private RecyclerView rvCallHistory; + + private CallHistoryAdapter adapter; + private CallApiService apiService; + private CallManager callManager; + + private int currentPage = 1; + private boolean isLoading = false; + private boolean hasMore = true; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_call_history); + + initViews(); + initApiService(); + initCallManager(); + setupListeners(); + loadData(); + loadMissedCount(); + } + + private void initViews() { + btnBack = findViewById(R.id.btnBack); + tvMissedCount = findViewById(R.id.tvMissedCount); + swipeRefresh = findViewById(R.id.swipeRefresh); + rvCallHistory = findViewById(R.id.rvCallHistory); + + adapter = new CallHistoryAdapter(); + rvCallHistory.setLayoutManager(new LinearLayoutManager(this)); + rvCallHistory.setAdapter(adapter); + } + + private void initApiService() { + String baseUrl = ApiClient.getCurrentBaseUrl(this); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(chain -> { + String token = AuthStore.getToken(CallHistoryActivity.this); + okhttp3.Request.Builder req = chain.request().newBuilder(); + if (token != null && !token.isEmpty()) { + req.header("Authori-zation", token); + req.header("Authorization", token); + } + return chain.proceed(req.build()); + }) + .build(); + + Retrofit retrofit = new Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build(); + + apiService = retrofit.create(CallApiService.class); + } + + private void initCallManager() { + callManager = CallManager.getInstance(this); + } + + private void setupListeners() { + btnBack.setOnClickListener(v -> finish()); + + swipeRefresh.setOnRefreshListener(() -> { + currentPage = 1; + hasMore = true; + loadData(); + }); + + adapter.setOnItemClickListener(new CallHistoryAdapter.OnItemClickListener() { + @Override + public void onItemClick(CallRecordResponse record) { + // 点击查看详情或回拨 + showCallOptions(record); + } + + @Override + public void onCallClick(CallRecordResponse record) { + // 回拨 + makeCall(record); + } + }); + + // 加载更多 + rvCallHistory.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); + if (layoutManager != null) { + int totalItemCount = layoutManager.getItemCount(); + int lastVisibleItem = layoutManager.findLastVisibleItemPosition(); + if (!isLoading && hasMore && lastVisibleItem >= totalItemCount - 3) { + loadMore(); + } + } + } + }); + } + + private void loadData() { + isLoading = true; + apiService.getCallHistory(currentPage, 20).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + swipeRefresh.setRefreshing(false); + isLoading = false; + + if (response.isSuccessful() && response.body() != null && response.body().isSuccess()) { + CallHistoryResponse data = response.body().getData(); + if (data != null && data.getList() != null) { + if (currentPage == 1) { + adapter.setRecords(data.getList()); + } else { + adapter.addRecords(data.getList()); + } + hasMore = data.getList().size() >= 20; + } + } else { + Toast.makeText(CallHistoryActivity.this, "加载失败", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call> call, Throwable t) { + swipeRefresh.setRefreshing(false); + isLoading = false; + Toast.makeText(CallHistoryActivity.this, "网络错误", Toast.LENGTH_SHORT).show(); + } + }); + } + + private void loadMore() { + currentPage++; + loadData(); + } + + private void loadMissedCount() { + apiService.getMissedCallCount().enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + if (response.isSuccessful() && response.body() != null && response.body().isSuccess()) { + Integer count = response.body().getData(); + if (count != null && count > 0) { + tvMissedCount.setText(count + "未接"); + tvMissedCount.setVisibility(android.view.View.VISIBLE); + } + } + } + + @Override + public void onFailure(Call> call, Throwable t) { + // 忽略错误 + } + }); + } + + private void showCallOptions(CallRecordResponse record) { + // 可以显示底部弹窗,提供回拨、删除等选项 + // 这里简化处理,直接回拨 + makeCall(record); + } + + private void makeCall(CallRecordResponse record) { + String callType = record.getCallType(); + int otherUserId = record.getOtherUserId(); + + callManager.initiateCall(otherUserId, callType, new CallManager.CallCallback() { + @Override + public void onSuccess(InitiateCallResponse response) { + // 通话界面会自动启动 + } + + @Override + public void onError(String error) { + Toast.makeText(CallHistoryActivity.this, "呼叫失败: " + error, Toast.LENGTH_SHORT).show(); + } + }); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryAdapter.java new file mode 100644 index 00000000..3fb37cc7 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryAdapter.java @@ -0,0 +1,165 @@ +package com.example.livestreaming.call; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.example.livestreaming.R; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import de.hdodenhof.circleimageview.CircleImageView; + +/** + * 通话记录列表适配器 + */ +public class CallHistoryAdapter extends RecyclerView.Adapter { + + private List records = new ArrayList<>(); + private OnItemClickListener listener; + private SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault()); + private SimpleDateFormat dateFormat = new SimpleDateFormat("MM-dd", Locale.getDefault()); + + public interface OnItemClickListener { + void onItemClick(CallRecordResponse record); + void onCallClick(CallRecordResponse record); + } + + public void setOnItemClickListener(OnItemClickListener listener) { + this.listener = listener; + } + + public void setRecords(List records) { + this.records = records != null ? records : new ArrayList<>(); + notifyDataSetChanged(); + } + + public void addRecords(List newRecords) { + if (newRecords != null && !newRecords.isEmpty()) { + int start = records.size(); + records.addAll(newRecords); + notifyItemRangeInserted(start, newRecords.size()); + } + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_call_record, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + CallRecordResponse record = records.get(position); + holder.bind(record); + } + + @Override + public int getItemCount() { + return records.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + CircleImageView ivAvatar; + TextView tvUserName; + ImageView ivCallType; + ImageView ivDirection; + TextView tvStatus; + TextView tvTime; + ImageButton btnCall; + + ViewHolder(@NonNull View itemView) { + super(itemView); + ivAvatar = itemView.findViewById(R.id.ivAvatar); + tvUserName = itemView.findViewById(R.id.tvUserName); + ivCallType = itemView.findViewById(R.id.ivCallType); + ivDirection = itemView.findViewById(R.id.ivDirection); + tvStatus = itemView.findViewById(R.id.tvStatus); + tvTime = itemView.findViewById(R.id.tvTime); + btnCall = itemView.findViewById(R.id.btnCall); + } + + void bind(CallRecordResponse record) { + // 用户名和头像 + tvUserName.setText(record.getOtherUserName() != null ? + record.getOtherUserName() : "用户" + record.getOtherUserId()); + + if (record.getOtherUserAvatar() != null && !record.getOtherUserAvatar().isEmpty()) { + Glide.with(itemView.getContext()) + .load(record.getOtherUserAvatar()) + .into(ivAvatar); + } else { + ivAvatar.setImageResource(R.drawable.ic_default_avatar); + } + + // 通话类型图标 + ivCallType.setImageResource("video".equals(record.getCallType()) ? + R.drawable.ic_videocam : R.drawable.ic_call); + + // 呼入/呼出方向和状态 + String status = record.getStatus(); + boolean isOutgoing = record.isOutgoing(); + + if ("missed".equals(status)) { + ivDirection.setImageResource(R.drawable.ic_call_missed); + tvStatus.setText("未接听"); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.red)); + } else if ("rejected".equals(status)) { + ivDirection.setImageResource(isOutgoing ? R.drawable.ic_call_made : R.drawable.ic_call_received); + tvStatus.setText(isOutgoing ? "对方已拒绝" : "已拒绝"); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.text_secondary)); + } else if ("cancelled".equals(status)) { + ivDirection.setImageResource(R.drawable.ic_call_made); + tvStatus.setText("已取消"); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.text_secondary)); + } else if ("ended".equals(status)) { + ivDirection.setImageResource(isOutgoing ? R.drawable.ic_call_made : R.drawable.ic_call_received); + String durationText = record.getDurationText(); + tvStatus.setText(durationText.isEmpty() ? "已结束" : "通话 " + durationText); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.text_secondary)); + } else { + ivDirection.setImageResource(isOutgoing ? R.drawable.ic_call_made : R.drawable.ic_call_received); + tvStatus.setText(isOutgoing ? "呼出" : "呼入"); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.text_secondary)); + } + + // 时间 + if (record.getCallTime() != null) { + Date callDate = new Date(record.getCallTime()); + Date today = new Date(); + if (isSameDay(callDate, today)) { + tvTime.setText(timeFormat.format(callDate)); + } else { + tvTime.setText(dateFormat.format(callDate)); + } + } + + // 点击事件 + itemView.setOnClickListener(v -> { + if (listener != null) listener.onItemClick(record); + }); + + btnCall.setOnClickListener(v -> { + if (listener != null) listener.onCallClick(record); + }); + } + + private boolean isSameDay(Date date1, Date date2) { + SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMdd", Locale.getDefault()); + return fmt.format(date1).equals(fmt.format(date2)); + } + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryResponse.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryResponse.java new file mode 100644 index 00000000..b3b2b546 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryResponse.java @@ -0,0 +1,45 @@ +package com.example.livestreaming.call; + +import java.util.List; + +/** + * 通话历史响应 + */ +public class CallHistoryResponse { + private List list; + private int total; + private int pageNum; + private int pageSize; + + public List getList() { + return list; + } + + public void setList(List list) { + this.list = list; + } + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + } + + public int getPageNum() { + return pageNum; + } + + public void setPageNum(int pageNum) { + this.pageNum = pageNum; + } + + public int getPageSize() { + return pageSize; + } + + public void setPageSize(int pageSize) { + this.pageSize = pageSize; + } +} 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 new file mode 100644 index 00000000..31b4835b --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallManager.java @@ -0,0 +1,437 @@ +package com.example.livestreaming.call; + +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.example.livestreaming.net.ApiClient; +import com.example.livestreaming.net.AuthStore; + +import org.json.JSONObject; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +/** + * 通话管理器 - 单例模式 + */ +public class CallManager implements CallSignalingClient.SignalingListener { + private static final String TAG = "CallManager"; + private static CallManager instance; + + private Context context; + private CallSignalingClient signalingClient; + private CallApiService apiService; + private CallStateListener stateListener; + + private String currentCallId; + private String currentCallType; + private boolean isCaller; + private int otherUserId; + private String otherUserName; + private String otherUserAvatar; + + public interface CallStateListener { + void onCallStateChanged(String state, String callId); + void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType); + void onCallConnected(String callId); + void onCallEnded(String callId, String reason); + void onError(String error); + } + + private CallManager(Context context) { + this.context = context.getApplicationContext(); + initApiService(); + } + + public static synchronized CallManager getInstance(Context context) { + if (instance == null) { + instance = new CallManager(context); + } + return instance; + } + + private void initApiService() { + String baseUrl = ApiClient.getCurrentBaseUrl(context); + Retrofit retrofit = new Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .client(ApiClient.getService(context).hashCode() > 0 ? + new okhttp3.OkHttpClient.Builder() + .addInterceptor(chain -> { + String token = AuthStore.getToken(context); + okhttp3.Request.Builder req = chain.request().newBuilder(); + if (token != null && !token.isEmpty()) { + req.header("Authori-zation", token); + req.header("Authorization", token); + } + return chain.proceed(req.build()); + }) + .build() : new okhttp3.OkHttpClient()) + .build(); + apiService = retrofit.create(CallApiService.class); + } + + public void setStateListener(CallStateListener listener) { + this.stateListener = listener; + } + + /** + * 连接信令服务器 + */ + public void connect(int userId) { + Log.d(TAG, "connect() called, userId: " + userId); + if (signalingClient != null && signalingClient.isConnected()) { + Log.d(TAG, "已经连接,跳过"); + return; + } + String baseUrl = ApiClient.getCurrentBaseUrl(context); + Log.d(TAG, "连接信令服务器,baseUrl: " + baseUrl); + signalingClient = new CallSignalingClient(baseUrl, userId); + signalingClient.setListener(this); + signalingClient.connect(); + } + + /** + * 断开信令服务器 + */ + public void disconnect() { + if (signalingClient != null) { + signalingClient.disconnect(); + signalingClient = null; + } + } + + /** + * 发起通话 + */ + public void initiateCall(int calleeId, String callType, CallCallback callback) { + // 确保WebSocket已连接,这样才能收到对方的接听/拒绝通知 + String userId = com.example.livestreaming.net.AuthStore.getUserId(context); + int uid = 0; + if (userId != null && !userId.isEmpty()) { + try { + uid = (int) Double.parseDouble(userId); + } catch (NumberFormatException e) { + Log.e(TAG, "解析用户ID失败", e); + } + } + + final int finalUid = uid; + + // 如果WebSocket未连接,先连接再发起通话 + if (signalingClient == null || !signalingClient.isConnected()) { + Log.d(TAG, "发起通话前连接WebSocket, userId=" + finalUid); + connect(finalUid); + // 延迟发起通话,等待WebSocket连接 + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + Log.d(TAG, "WebSocket连接状态: " + (signalingClient != null && signalingClient.isConnected())); + doInitiateCall(calleeId, callType, callback); + }, 1000); + } else { + Log.d(TAG, "WebSocket已连接,直接发起通话"); + doInitiateCall(calleeId, callType, callback); + } + } + + private void doInitiateCall(int calleeId, String callType, CallCallback callback) { + InitiateCallRequest request = new InitiateCallRequest(calleeId, callType); + apiService.initiateCall(request).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + if (response.isSuccessful() && response.body() != null && response.body().isSuccess()) { + InitiateCallResponse data = response.body().getData(); + currentCallId = data.getCallId(); + currentCallType = data.getCallType(); + isCaller = true; + otherUserId = data.getCalleeId(); + otherUserName = data.getCalleeName(); + otherUserAvatar = data.getCalleeAvatar(); + + Log.d(TAG, "通话创建成功: callId=" + currentCallId + ", WebSocket连接=" + + (signalingClient != null && signalingClient.isConnected())); + + // 启动通话界面 + startCallActivity(true); + + if (callback != null) callback.onSuccess(data); + } else { + String msg = response.body() != null ? response.body().getMessage() : "发起通话失败"; + if (callback != null) callback.onError(msg); + } + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "Initiate call failed", t); + if (callback != null) callback.onError(t.getMessage()); + } + }); + } + + /** + * 接听通话 + */ + public void acceptCall(String callId) { + Log.d(TAG, "acceptCall: callId=" + callId); + + // 确保WebSocket已连接 + if (signalingClient == null || !signalingClient.isConnected()) { + Log.w(TAG, "WebSocket未连接,尝试重新连接"); + // 尝试获取当前用户ID并连接 + String userId = com.example.livestreaming.net.AuthStore.getUserId(context); + if (userId != null && !userId.isEmpty()) { + try { + int uid = (int) Double.parseDouble(userId); + connect(uid); + // 延迟发送接听消息,等待连接建立 + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + if (signalingClient != null && signalingClient.isConnected()) { + signalingClient.sendCallAccept(callId); + Log.d(TAG, "延迟发送接听消息成功"); + } + }, 500); + } catch (NumberFormatException e) { + Log.e(TAG, "解析用户ID失败", e); + } + } + } else { + signalingClient.sendCallAccept(callId); + Log.d(TAG, "发送接听消息"); + } + + apiService.acceptCall(callId).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + Log.d(TAG, "Accept call API response: success=" + response.isSuccessful() + + ", code=" + response.code() + + ", body=" + (response.body() != null ? response.body().getMessage() : "null")); + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "Accept call API failed: " + t.getMessage(), t); + } + }); + } + + /** + * 拒绝通话 + */ + public void rejectCall(String callId) { + if (signalingClient != null) { + signalingClient.sendCallReject(callId); + } + apiService.rejectCall(callId).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + Log.d(TAG, "Reject call response: " + (response.isSuccessful())); + clearCallState(); + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "Reject call failed", t); + clearCallState(); + } + }); + } + + /** + * 取消通话 + */ + public void cancelCall(String callId) { + if (signalingClient != null) { + signalingClient.sendCallCancel(callId); + } + apiService.cancelCall(callId).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + Log.d(TAG, "Cancel call response: " + (response.isSuccessful())); + clearCallState(); + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "Cancel call failed", t); + clearCallState(); + } + }); + } + + /** + * 结束通话 + */ + public void endCall(String callId, String reason) { + if (signalingClient != null) { + signalingClient.sendCallEnd(callId, reason); + } + apiService.endCall(callId, reason).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + Log.d(TAG, "End call response: " + (response.isSuccessful())); + clearCallState(); + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "End call failed", t); + clearCallState(); + } + }); + } + + private void startCallActivity(boolean isCaller) { + Intent intent = new Intent(context, CallActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("callId", currentCallId); + intent.putExtra("callType", currentCallType); + intent.putExtra("isCaller", isCaller); + intent.putExtra("otherUserId", otherUserId); + intent.putExtra("otherUserName", otherUserName); + intent.putExtra("otherUserAvatar", otherUserAvatar); + context.startActivity(intent); + } + + private void clearCallState() { + currentCallId = null; + currentCallType = null; + isCaller = false; + otherUserId = 0; + otherUserName = null; + otherUserAvatar = null; + } + + public String getCurrentCallId() { + return currentCallId; + } + + public boolean isInCall() { + return currentCallId != null; + } + + // SignalingListener 实现 + @Override + public void onConnected() { + Log.d(TAG, "Signaling connected - WebSocket连接成功并已注册"); + } + + @Override + public void onDisconnected() { + Log.d(TAG, "Signaling disconnected - WebSocket断开连接"); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Signaling error: " + error); + if (stateListener != null) { + stateListener.onError(error); + } + } + + @Override + public void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType) { + Log.d(TAG, "========== 收到来电通知 =========="); + Log.d(TAG, "callId: " + callId); + Log.d(TAG, "callerId: " + callerId); + Log.d(TAG, "callerName: " + callerName); + Log.d(TAG, "callType: " + callType); + + currentCallId = callId; + currentCallType = callType; + isCaller = false; + otherUserId = callerId; + otherUserName = callerName; + otherUserAvatar = callerAvatar; + + // 启动来电界面 + Log.d(TAG, "启动来电界面 IncomingCallActivity"); + Intent intent = new Intent(context, IncomingCallActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("callId", callId); + intent.putExtra("callType", callType); + intent.putExtra("callerId", callerId); + intent.putExtra("callerName", callerName); + intent.putExtra("callerAvatar", callerAvatar); + context.startActivity(intent); + + if (stateListener != null) { + stateListener.onIncomingCall(callId, callerId, callerName, callerAvatar, callType); + } + } + + @Override + public void onCallAccepted(String callId) { + Log.d(TAG, "========== 收到通话接听通知 =========="); + Log.d(TAG, "callId: " + callId); + Log.d(TAG, "currentCallId: " + currentCallId); + Log.d(TAG, "stateListener: " + (stateListener != null ? stateListener.getClass().getSimpleName() : "null")); + + if (stateListener != null) { + Log.d(TAG, "调用 stateListener.onCallConnected"); + stateListener.onCallConnected(callId); + } else { + Log.w(TAG, "stateListener 为空,无法通知界面"); + } + } + + @Override + public void onCallRejected(String callId) { + Log.d(TAG, "Call rejected: " + callId); + if (stateListener != null) { + stateListener.onCallEnded(callId, "rejected"); + } + clearCallState(); + } + + @Override + public void onCallCancelled(String callId) { + Log.d(TAG, "Call cancelled: " + callId); + if (stateListener != null) { + stateListener.onCallEnded(callId, "cancelled"); + } + clearCallState(); + } + + @Override + public void onCallEnded(String callId, String reason) { + Log.d(TAG, "Call ended: " + callId + ", reason: " + reason); + if (stateListener != null) { + stateListener.onCallEnded(callId, reason); + } + clearCallState(); + } + + @Override + public void onCallTimeout(String callId) { + Log.d(TAG, "Call timeout: " + callId); + if (stateListener != null) { + stateListener.onCallEnded(callId, "timeout"); + } + clearCallState(); + } + + @Override + public void onOffer(String callId, String sdp) { + // WebRTC offer处理 - 后续实现 + } + + @Override + public void onAnswer(String callId, String sdp) { + // WebRTC answer处理 - 后续实现 + } + + @Override + public void onIceCandidate(String callId, JSONObject candidate) { + // WebRTC ICE candidate处理 - 后续实现 + } + + public interface CallCallback { + void onSuccess(InitiateCallResponse response); + void onError(String error); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallRecordResponse.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallRecordResponse.java new file mode 100644 index 00000000..bc26514c --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallRecordResponse.java @@ -0,0 +1,108 @@ +package com.example.livestreaming.call; + +/** + * 通话记录响应 + */ +public class CallRecordResponse { + private long id; + private String callId; + private int callerId; + private int calleeId; + private String callType; + private String status; + private Long callTime; + private Long connectTime; + private Long endTime; + private int duration; + private boolean isOutgoing; + private String callerName; + private String callerAvatar; + private String calleeName; + private String calleeAvatar; + private int otherUserId; + private String otherUserName; + private String otherUserAvatar; + + // Getters and Setters + public long getId() { return id; } + public void setId(long id) { this.id = id; } + + public String getCallId() { return callId; } + public void setCallId(String callId) { this.callId = callId; } + + public int getCallerId() { return callerId; } + public void setCallerId(int callerId) { this.callerId = callerId; } + + public int getCalleeId() { return calleeId; } + public void setCalleeId(int calleeId) { this.calleeId = calleeId; } + + public String getCallType() { return callType; } + public void setCallType(String callType) { this.callType = callType; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public Long getCallTime() { return callTime; } + public void setCallTime(Long callTime) { this.callTime = callTime; } + + public Long getConnectTime() { return connectTime; } + public void setConnectTime(Long connectTime) { this.connectTime = connectTime; } + + public Long getEndTime() { return endTime; } + public void setEndTime(Long endTime) { this.endTime = endTime; } + + public int getDuration() { return duration; } + public void setDuration(int duration) { this.duration = duration; } + + public boolean isOutgoing() { return isOutgoing; } + public void setOutgoing(boolean outgoing) { isOutgoing = outgoing; } + + public String getCallerName() { return callerName; } + public void setCallerName(String callerName) { this.callerName = callerName; } + + public String getCallerAvatar() { return callerAvatar; } + public void setCallerAvatar(String callerAvatar) { this.callerAvatar = callerAvatar; } + + public String getCalleeName() { return calleeName; } + public void setCalleeName(String calleeName) { this.calleeName = calleeName; } + + public String getCalleeAvatar() { return calleeAvatar; } + public void setCalleeAvatar(String calleeAvatar) { this.calleeAvatar = calleeAvatar; } + + public int getOtherUserId() { return otherUserId; } + public void setOtherUserId(int otherUserId) { this.otherUserId = otherUserId; } + + public String getOtherUserName() { return otherUserName; } + public void setOtherUserName(String otherUserName) { this.otherUserName = otherUserName; } + + public String getOtherUserAvatar() { return otherUserAvatar; } + public void setOtherUserAvatar(String otherUserAvatar) { this.otherUserAvatar = otherUserAvatar; } + + /** + * 获取状态显示文本 + */ + public String getStatusText() { + if (status == null) return ""; + switch (status) { + case "ended": return "已结束"; + case "missed": return "未接听"; + case "rejected": return "已拒绝"; + case "cancelled": return "已取消"; + case "busy": return "对方忙"; + default: return status; + } + } + + /** + * 获取通话时长显示文本 + */ + public String getDurationText() { + if (duration <= 0) return ""; + int minutes = duration / 60; + int seconds = duration % 60; + if (minutes > 0) { + return String.format("%d分%d秒", minutes, seconds); + } + return String.format("%d秒", seconds); + } +} 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 new file mode 100644 index 00000000..43706c24 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallSignalingClient.java @@ -0,0 +1,379 @@ +package com.example.livestreaming.call; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; + +/** + * 通话信令WebSocket客户端 + */ +public class CallSignalingClient { + private static final String TAG = "CallSignalingClient"; + + private WebSocket webSocket; + private OkHttpClient client; + private SignalingListener listener; + private Handler mainHandler; + private String baseUrl; + private int userId; + private boolean isConnected = false; + + public interface SignalingListener { + void onConnected(); + void onDisconnected(); + void onError(String error); + void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType); + void onCallAccepted(String callId); + void onCallRejected(String callId); + void onCallCancelled(String callId); + void onCallEnded(String callId, String reason); + void onCallTimeout(String callId); + void onOffer(String callId, String sdp); + void onAnswer(String callId, String sdp); + void onIceCandidate(String callId, JSONObject candidate); + } + + public CallSignalingClient(String baseUrl, int userId) { + this.baseUrl = baseUrl; + this.userId = userId; + this.client = new OkHttpClient(); + this.mainHandler = new Handler(Looper.getMainLooper()); + } + + public void setListener(SignalingListener listener) { + this.listener = listener; + } + + public void connect() { + String wsUrl = baseUrl.replace("http://", "ws://").replace("https://", "wss://"); + if (!wsUrl.endsWith("/")) wsUrl += "/"; + wsUrl += "ws/call"; + + Log.d(TAG, "Connecting to: " + wsUrl); + + 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"); + isConnected = true; + register(); + notifyConnected(); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + Log.d(TAG, "Received: " + text); + handleMessage(text); + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + Log.d(TAG, "WebSocket closing: " + reason); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + Log.d(TAG, "WebSocket closed: " + reason); + isConnected = false; + notifyDisconnected(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + Log.e(TAG, "WebSocket error", t); + isConnected = false; + notifyError(t.getMessage()); + } + }); + } + + public void disconnect() { + if (webSocket != null) { + webSocket.close(1000, "User disconnect"); + webSocket = null; + } + isConnected = false; + } + + public boolean isConnected() { + return isConnected; + } + + private void register() { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "register"); + msg.put("userId", userId); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Register error", e); + } + } + + public void sendCallRequest(int calleeId, String callType) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_request"); + msg.put("calleeId", calleeId); + msg.put("callType", callType); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call request error", e); + } + } + + public void sendCallAccept(String callId) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_accept"); + msg.put("callId", callId); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call accept error", e); + } + } + + public void sendCallReject(String callId) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_reject"); + msg.put("callId", callId); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call reject error", e); + } + } + + public void sendCallCancel(String callId) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_cancel"); + msg.put("callId", callId); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call cancel error", e); + } + } + + public void sendCallEnd(String callId, String reason) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_end"); + msg.put("callId", callId); + msg.put("reason", reason != null ? reason : "normal"); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call end error", e); + } + } + + public void sendOffer(String callId, String sdp) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "offer"); + msg.put("callId", callId); + msg.put("sdp", sdp); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send offer error", e); + } + } + + public void sendAnswer(String callId, String sdp) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "answer"); + msg.put("callId", callId); + msg.put("sdp", sdp); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send answer error", e); + } + } + + public void sendIceCandidate(String callId, JSONObject candidate) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "ice-candidate"); + msg.put("callId", callId); + msg.put("candidate", candidate); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send ICE candidate error", e); + } + } + + private void send(JSONObject msg) { + if (webSocket != null && isConnected) { + String text = msg.toString(); + Log.d(TAG, "Sending: " + text); + webSocket.send(text); + } + } + + private void handleMessage(String text) { + try { + JSONObject json = new JSONObject(text); + String type = json.optString("type", ""); + + switch (type) { + case "registered": + Log.d(TAG, "Registered successfully"); + break; + case "incoming_call": + handleIncomingCall(json); + break; + case "call_created": + // 通话创建成功,等待对方接听 + break; + case "call_accepted": + handleCallAccepted(json); + break; + case "call_rejected": + handleCallRejected(json); + break; + case "call_cancelled": + handleCallCancelled(json); + break; + case "call_ended": + handleCallEnded(json); + break; + case "call_timeout": + handleCallTimeout(json); + break; + case "offer": + handleOffer(json); + break; + case "answer": + handleAnswer(json); + break; + case "ice-candidate": + handleIceCandidate(json); + break; + case "error": + notifyError(json.optString("message", "Unknown error")); + break; + } + } catch (JSONException e) { + Log.e(TAG, "Parse message error", e); + } + } + + private void handleIncomingCall(JSONObject json) { + String callId = json.optString("callId"); + int callerId = json.optInt("callerId"); + String callerName = json.optString("callerName"); + String callerAvatar = json.optString("callerAvatar"); + String callType = json.optString("callType"); + + mainHandler.post(() -> { + if (listener != null) { + listener.onIncomingCall(callId, callerId, callerName, callerAvatar, callType); + } + }); + } + + private void handleCallAccepted(JSONObject json) { + String callId = json.optString("callId"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallAccepted(callId); + } + }); + } + + private void handleCallRejected(JSONObject json) { + String callId = json.optString("callId"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallRejected(callId); + } + }); + } + + private void handleCallCancelled(JSONObject json) { + String callId = json.optString("callId"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallCancelled(callId); + } + }); + } + + private void handleCallEnded(JSONObject json) { + String callId = json.optString("callId"); + String reason = json.optString("reason", "normal"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallEnded(callId, reason); + } + }); + } + + private void handleCallTimeout(JSONObject json) { + String callId = json.optString("callId"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallTimeout(callId); + } + }); + } + + private void handleOffer(JSONObject json) throws JSONException { + String callId = json.optString("callId"); + String sdp = json.optString("sdp"); + mainHandler.post(() -> { + if (listener != null) { + listener.onOffer(callId, sdp); + } + }); + } + + private void handleAnswer(JSONObject json) throws JSONException { + String callId = json.optString("callId"); + String sdp = json.optString("sdp"); + mainHandler.post(() -> { + if (listener != null) { + listener.onAnswer(callId, sdp); + } + }); + } + + private void handleIceCandidate(JSONObject json) throws JSONException { + String callId = json.optString("callId"); + JSONObject candidate = json.optJSONObject("candidate"); + mainHandler.post(() -> { + if (listener != null) { + listener.onIceCandidate(callId, candidate); + } + }); + } + + private void notifyConnected() { + mainHandler.post(() -> { + if (listener != null) listener.onConnected(); + }); + } + + private void notifyDisconnected() { + mainHandler.post(() -> { + if (listener != null) listener.onDisconnected(); + }); + } + + private void notifyError(String error) { + mainHandler.post(() -> { + if (listener != null) listener.onError(error); + }); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/IncomingCallActivity.java b/android-app/app/src/main/java/com/example/livestreaming/call/IncomingCallActivity.java new file mode 100644 index 00000000..46c4da60 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/IncomingCallActivity.java @@ -0,0 +1,216 @@ +package com.example.livestreaming.call; + +import android.content.Intent; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Vibrator; +import android.view.WindowManager; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +import com.bumptech.glide.Glide; +import com.example.livestreaming.R; + +/** + * 来电界面 + */ +public class IncomingCallActivity extends AppCompatActivity implements CallManager.CallStateListener { + + private ImageView ivBackgroundAvatar; + private ImageView ivAvatar; + private TextView tvUserName; + private TextView tvCallType; + private ImageButton btnReject; + private ImageButton btnAccept; + + private CallManager callManager; + private Ringtone ringtone; + private Vibrator vibrator; + + private String callId; + private String callType; + private int callerId; + private String callerName; + private String callerAvatar; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // 显示在锁屏上方,保持屏幕常亮 + getWindow().addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED | + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON | + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ); + + setContentView(R.layout.activity_incoming_call); + + initViews(); + initData(); + initCallManager(); + setupListeners(); + updateUI(); + startRinging(); + } + + private void initViews() { + ivBackgroundAvatar = findViewById(R.id.ivBackgroundAvatar); + ivAvatar = findViewById(R.id.ivAvatar); + tvUserName = findViewById(R.id.tvUserName); + tvCallType = findViewById(R.id.tvCallType); + btnReject = findViewById(R.id.btnReject); + btnAccept = findViewById(R.id.btnAccept); + } + + private void initData() { + callId = getIntent().getStringExtra("callId"); + callType = getIntent().getStringExtra("callType"); + callerId = getIntent().getIntExtra("callerId", 0); + callerName = getIntent().getStringExtra("callerName"); + callerAvatar = getIntent().getStringExtra("callerAvatar"); + } + + private void initCallManager() { + callManager = CallManager.getInstance(this); + callManager.setStateListener(this); + + // 确保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("IncomingCallActivity", "确保WebSocket连接,userId: " + uid); + callManager.connect(uid); + } + } catch (NumberFormatException e) { + android.util.Log.e("IncomingCallActivity", "解析用户ID失败", e); + } + } + } + + private void setupListeners() { + btnReject.setOnClickListener(v -> { + stopRinging(); + callManager.rejectCall(callId); + finish(); + }); + + btnAccept.setOnClickListener(v -> { + stopRinging(); + callManager.acceptCall(callId); + + // 跳转到通话界面,并标记为已接通 + Intent intent = new Intent(this, CallActivity.class); + intent.putExtra("callId", callId); + intent.putExtra("callType", callType); + intent.putExtra("isCaller", false); + intent.putExtra("otherUserId", callerId); + intent.putExtra("otherUserName", callerName); + intent.putExtra("otherUserAvatar", callerAvatar); + intent.putExtra("isConnected", true); // 被叫方接听后直接进入通话状态 + startActivity(intent); + finish(); + }); + } + + private void updateUI() { + tvUserName.setText(callerName != null ? callerName : "用户" + callerId); + tvCallType.setText("video".equals(callType) ? "视频来电" : "语音来电"); + + if (callerAvatar != null && !callerAvatar.isEmpty()) { + Glide.with(this).load(callerAvatar).into(ivAvatar); + Glide.with(this).load(callerAvatar).into(ivBackgroundAvatar); + } + } + + private void startRinging() { + // 播放铃声 + try { + Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + ringtone = RingtoneManager.getRingtone(this, ringtoneUri); + if (ringtone != null) { + ringtone.play(); + } + } catch (Exception e) { + e.printStackTrace(); + } + + // 震动 + try { + vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE); + if (vibrator != null && vibrator.hasVibrator()) { + long[] pattern = {0, 1000, 1000}; + vibrator.vibrate(pattern, 0); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + private void stopRinging() { + if (ringtone != null && ringtone.isPlaying()) { + ringtone.stop(); + } + if (vibrator != null) { + vibrator.cancel(); + } + } + + // CallStateListener 实现 + @Override + public void onCallStateChanged(String state, String callId) { + } + + @Override + public void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType) { + } + + @Override + public void onCallConnected(String callId) { + } + + @Override + public void onCallEnded(String callId, String reason) { + runOnUiThread(() -> { + stopRinging(); + if ("cancelled".equals(reason)) { + Toast.makeText(this, "对方已取消", Toast.LENGTH_SHORT).show(); + } else if ("timeout".equals(reason)) { + Toast.makeText(this, "来电已超时", Toast.LENGTH_SHORT).show(); + } + finish(); + }); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + stopRinging(); + Toast.makeText(this, "错误: " + error, Toast.LENGTH_SHORT).show(); + finish(); + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + stopRinging(); + if (callManager != null) { + callManager.setStateListener(null); + } + } + + @Override + public void onBackPressed() { + // 禁止返回键,必须接听或拒绝 + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallRequest.java b/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallRequest.java new file mode 100644 index 00000000..9d849209 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallRequest.java @@ -0,0 +1,30 @@ +package com.example.livestreaming.call; + +/** + * 发起通话请求 + */ +public class InitiateCallRequest { + private int calleeId; + private String callType; // "voice" 或 "video" + + public InitiateCallRequest(int calleeId, String callType) { + this.calleeId = calleeId; + this.callType = callType; + } + + public int getCalleeId() { + return calleeId; + } + + public void setCalleeId(int calleeId) { + this.calleeId = calleeId; + } + + public String getCallType() { + return callType; + } + + public void setCallType(String callType) { + this.callType = callType; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallResponse.java b/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallResponse.java new file mode 100644 index 00000000..b360c7a7 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallResponse.java @@ -0,0 +1,70 @@ +package com.example.livestreaming.call; + +/** + * 发起通话响应 + */ +public class InitiateCallResponse { + private String callId; + private String callType; + private int calleeId; + private String calleeName; + private String calleeAvatar; + private String status; + private String signalingUrl; + + public String getCallId() { + return callId; + } + + public void setCallId(String callId) { + this.callId = callId; + } + + public String getCallType() { + return callType; + } + + public void setCallType(String callType) { + this.callType = callType; + } + + public int getCalleeId() { + return calleeId; + } + + public void setCalleeId(int calleeId) { + this.calleeId = calleeId; + } + + public String getCalleeName() { + return calleeName; + } + + public void setCalleeName(String calleeName) { + this.calleeName = calleeName; + } + + public String getCalleeAvatar() { + return calleeAvatar; + } + + public void setCalleeAvatar(String calleeAvatar) { + this.calleeAvatar = calleeAvatar; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getSignalingUrl() { + return signalingUrl; + } + + public void setSignalingUrl(String signalingUrl) { + this.signalingUrl = signalingUrl; + } +} diff --git a/android-app/app/src/main/res/drawable/bg_accept_button.xml b/android-app/app/src/main/res/drawable/bg_accept_button.xml new file mode 100644 index 00000000..0994e54d --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_accept_button.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/bg_call_button.xml b/android-app/app/src/main/res/drawable/bg_call_button.xml new file mode 100644 index 00000000..bc4f0561 --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_call_button.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/bg_call_button_active.xml b/android-app/app/src/main/res/drawable/bg_call_button_active.xml new file mode 100644 index 00000000..9f3c01df --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_call_button_active.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/bg_hangup_button.xml b/android-app/app/src/main/res/drawable/bg_hangup_button.xml new file mode 100644 index 00000000..1ebbe3b8 --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_hangup_button.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_arrow_back.xml b/android-app/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 00000000..14fb7c89 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call.xml b/android-app/app/src/main/res/drawable/ic_call.xml new file mode 100644 index 00000000..fe445bf9 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call_end.xml b/android-app/app/src/main/res/drawable/ic_call_end.xml new file mode 100644 index 00000000..7f9b18d8 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call_end.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call_made.xml b/android-app/app/src/main/res/drawable/ic_call_made.xml new file mode 100644 index 00000000..63cade51 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call_made.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call_missed.xml b/android-app/app/src/main/res/drawable/ic_call_missed.xml new file mode 100644 index 00000000..191d4cf8 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call_missed.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call_received.xml b/android-app/app/src/main/res/drawable/ic_call_received.xml new file mode 100644 index 00000000..558b445e --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call_received.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_default_avatar.xml b/android-app/app/src/main/res/drawable/ic_default_avatar.xml new file mode 100644 index 00000000..ec8bbc43 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_default_avatar.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_mic.xml b/android-app/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 00000000..39ba5e21 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_mic_off.xml b/android-app/app/src/main/res/drawable/ic_mic_off.xml new file mode 100644 index 00000000..ed56bce5 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_mic_off.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_speaker.xml b/android-app/app/src/main/res/drawable/ic_speaker.xml new file mode 100644 index 00000000..f5a15786 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_speaker.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_switch_camera.xml b/android-app/app/src/main/res/drawable/ic_switch_camera.xml new file mode 100644 index 00000000..1236d5e9 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_switch_camera.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_videocam.xml b/android-app/app/src/main/res/drawable/ic_videocam.xml new file mode 100644 index 00000000..57d3e17a --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_videocam.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/layout/activity_call.xml b/android-app/app/src/main/res/layout/activity_call.xml new file mode 100644 index 00000000..ca8d27b5 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_call.xml @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_call_history.xml b/android-app/app/src/main/res/layout/activity_call_history.xml new file mode 100644 index 00000000..d0c7098a --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_call_history.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_conversation.xml b/android-app/app/src/main/res/layout/activity_conversation.xml index bf9e16d3..763efa19 100644 --- a/android-app/app/src/main/res/layout/activity_conversation.xml +++ b/android-app/app/src/main/res/layout/activity_conversation.xml @@ -52,10 +52,38 @@ android:textSize="16sp" android:textStyle="bold" app:layout_constraintBottom_toBottomOf="@id/avatarView" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@id/voiceCallButton" app:layout_constraintStart_toEndOf="@id/avatarView" app:layout_constraintTop_toTopOf="@id/avatarView" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/item_call_record.xml b/android-app/app/src/main/res/layout/item_call_record.xml new file mode 100644 index 00000000..e7550499 --- /dev/null +++ b/android-app/app/src/main/res/layout/item_call_record.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml index 16808c35..c86b6963 100644 --- a/android-app/app/src/main/res/values/colors.xml +++ b/android-app/app/src/main/res/values/colors.xml @@ -4,4 +4,14 @@ #03DAC5 #018786 #E53935 + + + #FF6B6B + #FFFFFF + #000000 + #FF3B30 + #34C759 + #212121 + #757575 + #F5F5F5