This commit is contained in:
userName 2025-12-26 18:37:57 +08:00
commit e8bfa466e4
47 changed files with 3362 additions and 40 deletions

View File

@ -1,6 +1,7 @@
package com.zbkj.front.config; package com.zbkj.front.config;
import com.zbkj.front.interceptor.WebSocketAuthInterceptor; import com.zbkj.front.interceptor.WebSocketAuthInterceptor;
import com.zbkj.front.websocket.CallSignalingHandler;
import com.zbkj.front.websocket.LiveChatHandler; import com.zbkj.front.websocket.LiveChatHandler;
import com.zbkj.front.websocket.PrivateChatHandler; import com.zbkj.front.websocket.PrivateChatHandler;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -25,13 +26,16 @@ public class WebSocketConfig implements WebSocketConfigurer {
private final LiveChatHandler liveChatHandler; private final LiveChatHandler liveChatHandler;
private final PrivateChatHandler privateChatHandler; private final PrivateChatHandler privateChatHandler;
private final CallSignalingHandler callSignalingHandler;
private final WebSocketAuthInterceptor webSocketAuthInterceptor; private final WebSocketAuthInterceptor webSocketAuthInterceptor;
public WebSocketConfig(LiveChatHandler liveChatHandler, public WebSocketConfig(LiveChatHandler liveChatHandler,
PrivateChatHandler privateChatHandler, PrivateChatHandler privateChatHandler,
CallSignalingHandler callSignalingHandler,
WebSocketAuthInterceptor webSocketAuthInterceptor) { WebSocketAuthInterceptor webSocketAuthInterceptor) {
this.liveChatHandler = liveChatHandler; this.liveChatHandler = liveChatHandler;
this.privateChatHandler = privateChatHandler; this.privateChatHandler = privateChatHandler;
this.callSignalingHandler = callSignalingHandler;
this.webSocketAuthInterceptor = webSocketAuthInterceptor; this.webSocketAuthInterceptor = webSocketAuthInterceptor;
} }
@ -48,5 +52,10 @@ public class WebSocketConfig implements WebSocketConfigurer {
registry.addHandler(privateChatHandler, "/ws/chat/{conversationId}") registry.addHandler(privateChatHandler, "/ws/chat/{conversationId}")
.addInterceptors(webSocketAuthInterceptor) .addInterceptors(webSocketAuthInterceptor)
.setAllowedOrigins("*"); .setAllowedOrigins("*");
// 通话信令 WebSocket 端点: ws://host:8081/ws/call
// 用于语音/视频通话的信令交换
registry.addHandler(callSignalingHandler, "/ws/call")
.setAllowedOrigins("*");
} }
} }

View File

@ -8,6 +8,7 @@ import com.zbkj.common.result.CommonResult;
import com.zbkj.front.request.call.InitiateCallRequest; import com.zbkj.front.request.call.InitiateCallRequest;
import com.zbkj.front.response.call.CallRecordResponse; import com.zbkj.front.response.call.CallRecordResponse;
import com.zbkj.front.response.call.InitiateCallResponse; import com.zbkj.front.response.call.InitiateCallResponse;
import com.zbkj.front.websocket.CallSignalingHandler;
import com.zbkj.service.service.CallService; import com.zbkj.service.service.CallService;
import com.zbkj.service.service.UserService; import com.zbkj.service.service.UserService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
@ -32,6 +33,9 @@ public class CallController {
@Autowired @Autowired
private UserService userService; private UserService userService;
@Autowired
private CallSignalingHandler callSignalingHandler;
@ApiOperation(value = "发起通话") @ApiOperation(value = "发起通话")
@PostMapping("/initiate") @PostMapping("/initiate")
public CommonResult<InitiateCallResponse> initiateCall(@RequestBody @Validated InitiateCallRequest request) { public CommonResult<InitiateCallResponse> initiateCall(@RequestBody @Validated InitiateCallRequest request) {
@ -40,6 +44,17 @@ public class CallController {
User callee = userService.getById(request.getCalleeId()); User callee = userService.getById(request.getCalleeId());
if (callee == null) return CommonResult.failed("被叫用户不存在"); if (callee == null) return CommonResult.failed("被叫用户不存在");
CallRecord record = callService.createCall(currentUser.getUid(), request.getCalleeId(), request.getCallType()); 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(); InitiateCallResponse response = new InitiateCallResponse();
response.setCallId(record.getCallId()); response.setCallId(record.getCallId());
response.setCallType(record.getCallType()); response.setCallType(record.getCallType());
@ -57,6 +72,19 @@ public class CallController {
public CommonResult<Boolean> acceptCall(@PathVariable String callId) { public CommonResult<Boolean> acceptCall(@PathVariable String callId) {
User currentUser = userService.getInfo(); User currentUser = userService.getInfo();
if (currentUser == null) return CommonResult.failed("请先登录"); 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())); return CommonResult.success(callService.acceptCall(callId, currentUser.getUid()));
} }
@ -65,6 +93,14 @@ public class CallController {
public CommonResult<Boolean> rejectCall(@PathVariable String callId) { public CommonResult<Boolean> rejectCall(@PathVariable String callId) {
User currentUser = userService.getInfo(); User currentUser = userService.getInfo();
if (currentUser == null) return CommonResult.failed("请先登录"); 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())); return CommonResult.success(callService.rejectCall(callId, currentUser.getUid()));
} }

View File

@ -45,6 +45,17 @@ public class ConversationController {
return CommonResult.success(conversationService.getConversationList(userId)); return CommonResult.success(conversationService.getConversationList(userId));
} }
/**
* 获取单个会话详情
*/
@ApiOperation(value = "获取单个会话详情")
@ApiImplicitParam(name = "id", value = "会话ID", required = true)
@GetMapping("/{id}")
public CommonResult<ConversationResponse> getConversationDetail(@PathVariable Long id) {
Integer userId = userService.getUserIdException();
return CommonResult.success(conversationService.getConversationDetail(id, userId));
}
/** /**
* 搜索会话 * 搜索会话
*/ */

View File

@ -105,6 +105,7 @@ public class CallSignalingHandler extends TextWebSocketHandler {
// 关闭旧连接 // 关闭旧连接
WebSocketSession oldSession = userCallSessions.get(userId); WebSocketSession oldSession = userCallSessions.get(userId);
if (oldSession != null && oldSession.isOpen() && !oldSession.getId().equals(session.getId())) { if (oldSession != null && oldSession.isOpen() && !oldSession.getId().equals(session.getId())) {
logger.info("[CallSignaling] 关闭旧连接: userId={}, oldSessionId={}", userId, oldSession.getId());
try { try {
oldSession.close(); oldSession.close();
} catch (Exception ignored) {} } catch (Exception ignored) {}
@ -117,7 +118,8 @@ public class CallSignalingHandler extends TextWebSocketHandler {
response.put("type", "registered"); response.put("type", "registered");
response.put("userId", userId); response.put("userId", userId);
session.sendMessage(new TextMessage(response.toString())); 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 { private void handleCallRequest(WebSocketSession session, JsonNode json) throws IOException {
@ -179,30 +181,50 @@ public class CallSignalingHandler extends TextWebSocketHandler {
Integer userId = sessionUserMap.get(session.getId()); Integer userId = sessionUserMap.get(session.getId());
String callId = json.has("callId") ? json.get("callId").asText() : null; String callId = json.has("callId") ? json.get("callId").asText() : null;
logger.info("[CallSignaling] 处理接听请求: callId={}, userId={}", callId, userId);
if (userId == null || callId == null) { if (userId == null || callId == null) {
sendError(session, "参数不完整"); sendError(session, "参数不完整");
return; return;
} }
try { 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); joinCallSession(callId, session);
sessionCallMap.put(session.getId(), callId); sessionCallMap.put(session.getId(), callId);
// 通知主叫方 // 通知主叫方
CallRecord record = callService.getByCallId(callId); Integer callerId = record.getCallerId();
if (record != null) { WebSocketSession callerSession = userCallSessions.get(callerId);
WebSocketSession callerSession = userCallSessions.get(record.getCallerId()); logger.info("[CallSignaling] 查找主叫方会话: callerId={}, session存在={}, session打开={}",
if (callerSession != null && callerSession.isOpen()) { callerId, callerSession != null, callerSession != null && callerSession.isOpen());
ObjectNode notify = objectMapper.createObjectNode();
notify.put("type", "call_accepted"); if (callerSession != null && callerSession.isOpen()) {
notify.put("callId", callId); ObjectNode notify = objectMapper.createObjectNode();
callerSession.sendMessage(new TextMessage(notify.toString())); 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) { } catch (Exception e) {
logger.error("[CallSignaling] 接听通话异常: callId={}, error={}", callId, e.getMessage(), e);
sendError(session, e.getMessage()); sendError(session, e.getMessage());
} }
} }
@ -445,4 +467,84 @@ public class CallSignalingHandler extends TextWebSocketHandler {
error.put("message", message); error.put("message", message);
session.sendMessage(new TextMessage(error.toString())); 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);
}
}
} }

View File

@ -18,6 +18,11 @@ public interface ConversationService extends IService<Conversation> {
*/ */
List<ConversationResponse> getConversationList(Integer userId); List<ConversationResponse> getConversationList(Integer userId);
/**
* 获取单个会话详情
*/
ConversationResponse getConversationDetail(Long conversationId, Integer userId);
/** /**
* 搜索会话 * 搜索会话
*/ */
@ -48,6 +53,16 @@ public interface ConversationService extends IService<Conversation> {
*/ */
Boolean deleteMessage(Long messageId, Integer userId); Boolean deleteMessage(Long messageId, Integer userId);
/**
* 撤回消息
*/
Boolean recallMessage(Long messageId, Integer userId);
/**
* 获取单条消息详情
*/
ChatMessageResponse getMessageById(Long messageId);
/** /**
* 获取或创建与指定用户的会话 * 获取或创建与指定用户的会话
*/ */

View File

@ -101,8 +101,16 @@ public class CallServiceImpl extends ServiceImpl<CallRecordDao, CallRecord> impl
CallRecord record = getByCallId(callId); CallRecord record = getByCallId(callId);
if (record == null) throw new CrmebException("通话记录不存在"); if (record == null) throw new CrmebException("通话记录不存在");
if (!record.getCalleeId().equals(userId)) 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())) { 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(); Date now = new Date();

View File

@ -47,6 +47,19 @@ public class ConversationServiceImpl extends ServiceImpl<ConversationDao, Conver
return convertToResponseList(conversations, userId); return convertToResponseList(conversations, userId);
} }
@Override
public ConversationResponse getConversationDetail(Long conversationId, Integer userId) {
Conversation conversation = getById(conversationId);
if (conversation == null) {
throw new CrmebException("会话不存在");
}
// 检查用户是否是会话参与者
if (!conversation.getUser1Id().equals(userId) && !conversation.getUser2Id().equals(userId)) {
throw new CrmebException("无权限查看此会话");
}
return convertToResponse(conversation, userId);
}
@Override @Override
public List<ConversationResponse> searchConversations(Integer userId, String keyword) { public List<ConversationResponse> searchConversations(Integer userId, String keyword) {
// 先获取用户的所有会话 // 先获取用户的所有会话
@ -262,35 +275,42 @@ public class ConversationServiceImpl extends ServiceImpl<ConversationDao, Conver
private List<ConversationResponse> convertToResponseList(List<Conversation> conversations, Integer userId) { private List<ConversationResponse> convertToResponseList(List<Conversation> conversations, Integer userId) {
List<ConversationResponse> result = new ArrayList<>(); List<ConversationResponse> result = new ArrayList<>();
for (Conversation conv : conversations) { for (Conversation conv : conversations) {
ConversationResponse response = new ConversationResponse(); result.add(convertToResponse(conv, userId));
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);
} }
return result; 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<ConversationDao, Conver
return privateMessageDao.selectById(messageId); return privateMessageDao.selectById(messageId);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean recallMessage(Long messageId, Integer userId) {
PrivateMessage message = privateMessageDao.selectById(messageId);
if (message == null) {
throw new CrmebException("消息不存在");
}
// 只有发送者才能撤回消息
if (!message.getSenderId().equals(userId)) {
throw new CrmebException("只能撤回自己发送的消息");
}
// 检查是否在2分钟内
long now = System.currentTimeMillis();
long messageTime = message.getCreateTime().getTime();
long diffMinutes = (now - messageTime) / (1000 * 60);
if (diffMinutes > 2) {
throw new CrmebException("消息发送超过2分钟无法撤回");
}
// 标记消息为已撤回
LambdaUpdateWrapper<PrivateMessage> 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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public PrivateMessage sendMessage(Long conversationId, Integer senderId, String messageType, String content, String mediaUrl) { public PrivateMessage sendMessage(Long conversationId, Integer senderId, String messageType, String content, String mediaUrl) {

View File

@ -8,6 +8,10 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application <application
android:name=".LiveStreamingApplication" android:name=".LiveStreamingApplication"
@ -177,6 +181,29 @@
android:exported="false" android:exported="false"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<!-- 通话相关Activity -->
<activity
android:name="com.example.livestreaming.call.CallActivity"
android:exported="false"
android:screenOrientation="portrait"
android:launchMode="singleTop"
android:showOnLockScreen="true"
android:turnScreenOn="true" />
<activity
android:name="com.example.livestreaming.call.IncomingCallActivity"
android:exported="false"
android:screenOrientation="portrait"
android:launchMode="singleTop"
android:showOnLockScreen="true"
android:turnScreenOn="true"
android:excludeFromRecents="true" />
<activity
android:name="com.example.livestreaming.call.CallHistoryActivity"
android:exported="false"
android:screenOrientation="portrait" />
</application> </application>
</manifest> </manifest>

View File

@ -77,6 +77,14 @@ public class ConversationActivity extends AppCompatActivity {
intent.putExtra(EXTRA_CONVERSATION_TITLE, title); intent.putExtra(EXTRA_CONVERSATION_TITLE, title);
context.startActivity(intent); 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 @Override
protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onCreate(@Nullable Bundle savedInstanceState) {
@ -687,6 +695,159 @@ public class ConversationActivity extends AppCompatActivity {
binding.messageInput.setOnFocusChangeListener((v, hasFocus) -> { binding.messageInput.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) scrollToBottom(); 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();
}
});
}
});
} }
/** /**

View File

@ -10,6 +10,8 @@ public class ConversationItem {
private final String timeText; private final String timeText;
private final int unreadCount; private final int unreadCount;
private final boolean muted; private final boolean muted;
private int otherUserId;
private String avatarUrl;
public ConversationItem(String id, String title, String lastMessage, String timeText, int unreadCount, boolean muted) { public ConversationItem(String id, String title, String lastMessage, String timeText, int unreadCount, boolean muted) {
this.id = id; this.id = id;
@ -44,6 +46,22 @@ public class ConversationItem {
return muted; 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 @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;

View File

@ -1,6 +1,10 @@
package com.example.livestreaming; package com.example.livestreaming;
import android.app.Application; import android.app.Application;
import android.util.Log;
import com.example.livestreaming.call.CallManager;
import com.example.livestreaming.net.AuthStore;
/** /**
* 自定义Application类用于初始化各种组件 * 自定义Application类用于初始化各种组件
@ -8,9 +12,13 @@ import android.app.Application;
*/ */
public class LiveStreamingApplication extends Application { public class LiveStreamingApplication extends Application {
private static final String TAG = "LiveStreamingApp";
private static LiveStreamingApplication instance;
@Override @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
instance = this;
// 初始化LeakCanary内存泄漏检测仅在debug版本中生效 // 初始化LeakCanary内存泄漏检测仅在debug版本中生效
// LeakCanary会自动在debug版本中初始化无需手动调用 // LeakCanary会自动在debug版本中初始化无需手动调用
@ -20,5 +28,50 @@ public class LiveStreamingApplication extends Application {
// 初始化通知渠道 // 初始化通知渠道
LocalNotificationManager.createNotificationChannel(this); 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();
} }
} }

View File

@ -149,6 +149,19 @@ public class LoginActivity extends AppCompatActivity {
prefs.edit().putString("profile_phone", loginData.getPhone()).apply(); 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(); Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show();

View File

@ -137,11 +137,12 @@ public class MessagesActivity extends AppCompatActivity {
conversationsAdapter = new ConversationsAdapter(item -> { conversationsAdapter = new ConversationsAdapter(item -> {
if (item == null) return; if (item == null) return;
try { try {
// 启动会话页面传递未读数量 // 启动会话页面传递未读数量和对方用户ID
Intent intent = new Intent(this, ConversationActivity.class); Intent intent = new Intent(this, ConversationActivity.class);
intent.putExtra("extra_conversation_id", item.getId()); intent.putExtra("extra_conversation_id", item.getId());
intent.putExtra("extra_conversation_title", item.getTitle()); intent.putExtra("extra_conversation_title", item.getTitle());
intent.putExtra("extra_unread_count", item.getUnreadCount()); intent.putExtra("extra_unread_count", item.getUnreadCount());
intent.putExtra("other_user_id", item.getOtherUserId());
startActivity(intent); startActivity(intent);
// 用户点击会话时减少该会话的未读数量 // 用户点击会话时减少该会话的未读数量
@ -231,8 +232,11 @@ public class MessagesActivity extends AppCompatActivity {
int unreadCount = item.optInt("unreadCount", 0); int unreadCount = item.optInt("unreadCount", 0);
boolean isMuted = item.optBoolean("muted", item.optBoolean("isMuted", false)); boolean isMuted = item.optBoolean("muted", item.optBoolean("isMuted", false));
String avatarUrl = item.optString("avatarUrl", item.optString("otherUserAvatar", "")); String avatarUrl = item.optString("avatarUrl", item.optString("otherUserAvatar", ""));
int otherUserId = item.optInt("otherUserId", 0);
ConversationItem convItem = new ConversationItem(id, title, lastMessage, timeText, unreadCount, isMuted); ConversationItem convItem = new ConversationItem(id, title, lastMessage, timeText, unreadCount, isMuted);
convItem.setOtherUserId(otherUserId);
convItem.setAvatarUrl(avatarUrl);
allConversations.add(convItem); allConversations.add(convItem);
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "解析会话项失败", e); Log.e(TAG, "解析会话项失败", e);

View File

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

View File

@ -0,0 +1,38 @@
package com.example.livestreaming.call;
/**
* 通话API响应包装类
*/
public class CallApiResponse<T> {
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;
}
}

View File

@ -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<CallApiResponse<InitiateCallResponse>> initiateCall(@Body InitiateCallRequest request);
/**
* 接听通话
*/
@POST("api/front/call/accept/{callId}")
Call<CallApiResponse<Boolean>> acceptCall(@Path("callId") String callId);
/**
* 拒绝通话
*/
@POST("api/front/call/reject/{callId}")
Call<CallApiResponse<Boolean>> rejectCall(@Path("callId") String callId);
/**
* 取消通话
*/
@POST("api/front/call/cancel/{callId}")
Call<CallApiResponse<Boolean>> cancelCall(@Path("callId") String callId);
/**
* 结束通话
*/
@POST("api/front/call/end/{callId}")
Call<CallApiResponse<Boolean>> endCall(@Path("callId") String callId, @Query("endReason") String endReason);
/**
* 获取通话记录
*/
@GET("api/front/call/history")
Call<CallApiResponse<CallHistoryResponse>> getCallHistory(
@Query("page") int page,
@Query("limit") int limit
);
/**
* 删除通话记录
*/
@DELETE("api/front/call/record/{recordId}")
Call<CallApiResponse<Boolean>> deleteCallRecord(@Path("recordId") long recordId);
/**
* 获取未接来电数量
*/
@GET("api/front/call/missed/count")
Call<CallApiResponse<Integer>> getMissedCallCount();
/**
* 获取通话状态
*/
@GET("api/front/call/status")
Call<CallApiResponse<Map<String, Object>>> getCallStatus();
/**
* 获取通话详情
*/
@GET("api/front/call/detail/{callId}")
Call<CallApiResponse<CallRecordResponse>> getCallDetail(@Path("callId") String callId);
}

View File

@ -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<CallApiResponse<CallHistoryResponse>>() {
@Override
public void onResponse(Call<CallApiResponse<CallHistoryResponse>> call,
Response<CallApiResponse<CallHistoryResponse>> 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<CallApiResponse<CallHistoryResponse>> 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<CallApiResponse<Integer>>() {
@Override
public void onResponse(Call<CallApiResponse<Integer>> call,
Response<CallApiResponse<Integer>> 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<CallApiResponse<Integer>> 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();
}
});
}
}

View File

@ -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<CallHistoryAdapter.ViewHolder> {
private List<CallRecordResponse> 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<CallRecordResponse> records) {
this.records = records != null ? records : new ArrayList<>();
notifyDataSetChanged();
}
public void addRecords(List<CallRecordResponse> 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));
}
}
}

View File

@ -0,0 +1,45 @@
package com.example.livestreaming.call;
import java.util.List;
/**
* 通话历史响应
*/
public class CallHistoryResponse {
private List<CallRecordResponse> list;
private int total;
private int pageNum;
private int pageSize;
public List<CallRecordResponse> getList() {
return list;
}
public void setList(List<CallRecordResponse> 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;
}
}

View File

@ -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<CallApiResponse<InitiateCallResponse>>() {
@Override
public void onResponse(Call<CallApiResponse<InitiateCallResponse>> call,
Response<CallApiResponse<InitiateCallResponse>> 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<CallApiResponse<InitiateCallResponse>> 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<CallApiResponse<Boolean>>() {
@Override
public void onResponse(Call<CallApiResponse<Boolean>> call, Response<CallApiResponse<Boolean>> 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<CallApiResponse<Boolean>> 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<CallApiResponse<Boolean>>() {
@Override
public void onResponse(Call<CallApiResponse<Boolean>> call, Response<CallApiResponse<Boolean>> response) {
Log.d(TAG, "Reject call response: " + (response.isSuccessful()));
clearCallState();
}
@Override
public void onFailure(Call<CallApiResponse<Boolean>> 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<CallApiResponse<Boolean>>() {
@Override
public void onResponse(Call<CallApiResponse<Boolean>> call, Response<CallApiResponse<Boolean>> response) {
Log.d(TAG, "Cancel call response: " + (response.isSuccessful()));
clearCallState();
}
@Override
public void onFailure(Call<CallApiResponse<Boolean>> 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<CallApiResponse<Boolean>>() {
@Override
public void onResponse(Call<CallApiResponse<Boolean>> call, Response<CallApiResponse<Boolean>> response) {
Log.d(TAG, "End call response: " + (response.isSuccessful()));
clearCallState();
}
@Override
public void onFailure(Call<CallApiResponse<Boolean>> 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);
}
}

View File

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

View File

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

View File

@ -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() {
// 禁止返回键必须接听或拒绝
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#34C759" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#33FFFFFF" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFF" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF3B30" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#4CAF50"
android:pathData="M9,5v2h6.59L4,18.59 5.41,20 17,8.41V15h2V5z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#F44336"
android:pathData="M19.59,7L12,14.59 6.41,9H11V7H3v8h2v-4.59l7,7 9,-9z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#2196F3"
android:pathData="M20,5.41L18.59,4 7,15.59V9H5v10h10v-2H8.41z"/>
</vector>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#E0E0E0"
android:pathData="M24,24m-24,0a24,24 0,1 1,48 0a24,24 0,1 1,-48 0"/>
<path
android:fillColor="#9E9E9E"
android:pathData="M24,12c3.31,0 6,2.69 6,6s-2.69,6 -6,6 -6,-2.69 -6,-6 2.69,-6 6,-6zM24,30c6.63,0 12,2.69 12,6v2H12v-2c0,-3.31 5.37,-6 12,-6z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01V11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M9,12c0,1.66 1.34,3 3,3s3,-1.34 3,-3 -1.34,-3 -3,-3 -3,1.34 -3,3zM17,8.54V4h-2v2H9V4H7v4.54l-4,4.46h2l2,-2.23V20h2v-4h6v4h2v-9.23l2,2.23h2l-4,-4.46z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M17,10.5V7c0,-0.55 -0.45,-1 -1,-1H4c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1v-3.5l4,4v-11l-4,4z"/>
</vector>

View File

@ -0,0 +1,239 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A2E">
<!-- 背景模糊头像 -->
<ImageView
android:id="@+id/ivBackgroundAvatar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:alpha="0.3" />
<!-- 视频通话时的远程视频 -->
<SurfaceView
android:id="@+id/remoteVideoView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<!-- 视频通话时的本地视频(小窗口) -->
<SurfaceView
android:id="@+id/localVideoView"
android:layout_width="120dp"
android:layout_height="160dp"
android:layout_gravity="top|end"
android:layout_marginTop="80dp"
android:layout_marginEnd="16dp"
android:visibility="gone" />
<!-- 主内容区域 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center_horizontal">
<!-- 顶部状态栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="16dp"
android:layout_marginTop="24dp">
<ImageButton
android:id="@+id/btnMinimize"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_arrow_back"
app:tint="@android:color/white" />
<TextView
android:id="@+id/tvCallType"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="语音通话"
android:textColor="@android:color/white"
android:textSize="16sp" />
<ImageButton
android:id="@+id/btnSwitchCamera"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_switch_camera"
android:visibility="gone"
app:tint="@android:color/white" />
</LinearLayout>
<!-- 用户信息区域 -->
<LinearLayout
android:id="@+id/layoutUserInfo"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:paddingTop="60dp">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/ivAvatar"
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@drawable/ic_default_avatar"
app:civ_border_color="#FFFFFF"
app:civ_border_width="3dp" />
<TextView
android:id="@+id/tvUserName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="用户名"
android:textColor="@android:color/white"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvCallStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="正在呼叫..."
android:textColor="#AAAAAA"
android:textSize="16sp" />
<TextView
android:id="@+id/tvCallDuration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="00:00"
android:textColor="@android:color/white"
android:textSize="18sp"
android:visibility="gone" />
</LinearLayout>
<!-- 底部控制按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingBottom="60dp">
<!-- 通话中的控制按钮 -->
<LinearLayout
android:id="@+id/layoutCallControls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:visibility="gone">
<!-- 静音按钮 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:layout_marginHorizontal="20dp">
<ImageButton
android:id="@+id/btnMute"
android:layout_width="60dp"
android:layout_height="60dp"
android:background="@drawable/bg_call_button"
android:src="@drawable/ic_mic"
app:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="静音"
android:textColor="@android:color/white"
android:textSize="12sp" />
</LinearLayout>
<!-- 免提按钮 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:layout_marginHorizontal="20dp">
<ImageButton
android:id="@+id/btnSpeaker"
android:layout_width="60dp"
android:layout_height="60dp"
android:background="@drawable/bg_call_button"
android:src="@drawable/ic_speaker"
app:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="免提"
android:textColor="@android:color/white"
android:textSize="12sp" />
</LinearLayout>
<!-- 视频按钮(仅视频通话显示) -->
<LinearLayout
android:id="@+id/layoutVideoToggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:layout_marginHorizontal="20dp"
android:visibility="gone">
<ImageButton
android:id="@+id/btnVideo"
android:layout_width="60dp"
android:layout_height="60dp"
android:background="@drawable/bg_call_button"
android:src="@drawable/ic_videocam"
app:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="摄像头"
android:textColor="@android:color/white"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<!-- 挂断按钮 -->
<ImageButton
android:id="@+id/btnHangup"
android:layout_width="70dp"
android:layout_height="70dp"
android:layout_marginTop="30dp"
android:background="@drawable/bg_hangup_button"
android:src="@drawable/ic_call_end"
app:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="挂断"
android:textColor="@android:color/white"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/background_color">
<!-- 顶部标题栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:background="@color/white">
<ImageButton
android:id="@+id/btnBack"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_arrow_back"
app:tint="@color/text_primary" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="通话记录"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvMissedCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0未接"
android:textColor="@color/red"
android:textSize="14sp"
android:visibility="gone" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#E0E0E0" />
<!-- 通话记录列表 -->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCallHistory"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingVertical="8dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@ -52,10 +52,38 @@
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/avatarView" 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_constraintStart_toEndOf="@id/avatarView"
app:layout_constraintTop_toTopOf="@id/avatarView" /> app:layout_constraintTop_toTopOf="@id/avatarView" />
<!-- 语音通话按钮 -->
<ImageButton
android:id="@+id/voiceCallButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="语音通话"
android:padding="8dp"
android:src="@drawable/ic_call"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/videoCallButton"
app:layout_constraintTop_toTopOf="parent"
app:tint="#FF6B6B" />
<!-- 视频通话按钮 -->
<ImageButton
android:id="@+id/videoCallButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="视频通话"
android:padding="8dp"
android:src="@drawable/ic_videocam"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="#FF6B6B" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<View <View

View File

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A2E">
<!-- 背景模糊头像 -->
<ImageView
android:id="@+id/ivBackgroundAvatar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:alpha="0.3" />
<!-- 主内容 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center_horizontal">
<!-- 顶部提示 -->
<TextView
android:id="@+id/tvCallType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:text="语音来电"
android:textColor="#AAAAAA"
android:textSize="16sp" />
<!-- 用户信息 -->
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/ivAvatar"
android:layout_width="140dp"
android:layout_height="140dp"
android:layout_marginTop="40dp"
android:src="@drawable/ic_default_avatar"
app:civ_border_color="#FFFFFF"
app:civ_border_width="3dp" />
<TextView
android:id="@+id/tvUserName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="用户名"
android:textColor="@android:color/white"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="邀请你进行通话"
android:textColor="#AAAAAA"
android:textSize="16sp" />
<!-- 占位 -->
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- 底部按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:paddingBottom="80dp">
<!-- 拒绝按钮 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:layout_marginHorizontal="40dp">
<ImageButton
android:id="@+id/btnReject"
android:layout_width="70dp"
android:layout_height="70dp"
android:background="@drawable/bg_hangup_button"
android:src="@drawable/ic_call_end"
app:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="拒绝"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<!-- 接听按钮 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:layout_marginHorizontal="40dp">
<ImageButton
android:id="@+id/btnAccept"
android:layout_width="70dp"
android:layout_height="70dp"
android:background="@drawable/bg_accept_button"
android:src="@drawable/ic_call"
app:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="接听"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>

View File

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="16dp"
android:background="?attr/selectableItemBackground">
<!-- 头像 -->
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/ivAvatar"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@drawable/ic_default_avatar" />
<!-- 用户信息 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvUserName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="用户名"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end" />
<!-- 通话类型图标 -->
<ImageView
android:id="@+id/ivCallType"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="6dp"
android:src="@drawable/ic_call"
app:tint="@color/text_secondary" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- 呼入/呼出图标 -->
<ImageView
android:id="@+id/ivDirection"
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@drawable/ic_call_made"
app:tint="@color/text_secondary" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="已接通 · 5分钟"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<!-- 时间和操作 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end">
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="10:30"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
<ImageButton
android:id="@+id/btnCall"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginTop="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_call"
app:tint="@color/primary" />
</LinearLayout>
</LinearLayout>

View File

@ -4,4 +4,14 @@
<color name="teal_200">#03DAC5</color> <color name="teal_200">#03DAC5</color>
<color name="teal_700">#018786</color> <color name="teal_700">#018786</color>
<color name="live_red">#E53935</color> <color name="live_red">#E53935</color>
<!-- 通用颜色 -->
<color name="primary">#FF6B6B</color>
<color name="white">#FFFFFF</color>
<color name="black">#000000</color>
<color name="red">#FF3B30</color>
<color name="green">#34C759</color>
<color name="text_primary">#212121</color>
<color name="text_secondary">#757575</color>
<color name="background_color">#F5F5F5</color>
</resources> </resources>