好友聊天功能实现

This commit is contained in:
xiao12feng8 2025-12-25 17:20:34 +08:00
parent 4bc5b6767d
commit bc0713b447
4 changed files with 557 additions and 152 deletions

View File

@ -8,8 +8,8 @@ import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import android.view.KeyEvent;
import android.view.MenuItem;
import android.view.View;
import androidx.annotation.Nullable;
@ -19,22 +19,44 @@ import androidx.appcompat.widget.PopupMenu;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.livestreaming.databinding.ActivityConversationBinding;
import com.example.livestreaming.net.AuthStore;
import com.google.android.material.snackbar.Snackbar;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
public class ConversationActivity extends AppCompatActivity {
private static final String TAG = "ConversationActivity";
private static final String EXTRA_CONVERSATION_ID = "extra_conversation_id";
private static final String EXTRA_CONVERSATION_TITLE = "extra_conversation_title";
private static final String EXTRA_UNREAD_COUNT = "extra_unread_count";
private static final int PAGE_SIZE = 20;
private ActivityConversationBinding binding;
private final OkHttpClient httpClient = new OkHttpClient();
private ConversationMessagesAdapter adapter;
private final List<ChatMessage> messages = new ArrayList<>();
private Handler handler;
private Runnable statusUpdateRunnable;
private String conversationId;
private int currentPage = 1;
private int initialUnreadCount = 0;
private String currentUserId;
public static void start(Context context, String conversationId, String title) {
Intent intent = new Intent(context, ConversationActivity.class);
@ -43,10 +65,6 @@ public class ConversationActivity extends AppCompatActivity {
context.startActivity(intent);
}
private static final String EXTRA_UNREAD_COUNT = "extra_unread_count";
private int initialUnreadCount = 0;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@ -58,8 +76,9 @@ public class ConversationActivity extends AppCompatActivity {
String title = getIntent() != null ? getIntent().getStringExtra(EXTRA_CONVERSATION_TITLE) : null;
binding.titleText.setText(title != null ? title : "会话");
// 获取该会话的初始未读数量
conversationId = getIntent() != null ? getIntent().getStringExtra(EXTRA_CONVERSATION_ID) : null;
initialUnreadCount = getIntent() != null ? getIntent().getIntExtra(EXTRA_UNREAD_COUNT, 0) : 0;
currentUserId = AuthStore.getUserId(this);
binding.backButton.setOnClickListener(new DebounceClickListener() {
@Override
@ -71,28 +90,19 @@ public class ConversationActivity extends AppCompatActivity {
setupMessages();
setupInput();
// TODO: 接入后端接口 - 标记会话消息为已读
// 接口路径: POST /api/conversations/{conversationId}/read
// 请求参数:
// - conversationId: 会话ID路径参数
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<{success: boolean}>
// 用户进入会话时标记该会话的所有消息为已读减少未读数量
if (initialUnreadCount > 0) {
UnreadMessageManager.decrementUnreadCount(this, initialUnreadCount);
// 标记会话为已读
if (initialUnreadCount > 0 && conversationId != null) {
markConversationAsRead();
}
}
@Override
protected void onPause() {
super.onPause();
// 当用户离开会话时更新总未读数量如果会话未读数量已减少
}
private void setupMessages() {
adapter = new ConversationMessagesAdapter();
// 设置长按监听
adapter.setOnMessageLongClickListener((message, position, view) -> {
showMessageMenu(message, position, view);
});
@ -102,43 +112,159 @@ public class ConversationActivity extends AppCompatActivity {
binding.messagesRecyclerView.setLayoutManager(layoutManager);
binding.messagesRecyclerView.setAdapter(adapter);
// TODO: 接入后端接口 - 获取会话消息列表
// 接口路径: GET /api/conversations/{conversationId}/messages
// 请求参数:
// - conversationId: 会话ID路径参数
// - page (可选): 页码用于分页加载历史消息
// - pageSize (可选): 每页数量默认20
// - beforeMessageId (可选): 获取指定消息ID之前的消息用于上拉加载更多
// 返回数据格式: ApiResponse<List<ChatMessage>>
// ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp, status等字段
// 消息列表应按时间正序排列最早的在前面
loadMessagesFromServer();
}
/**
* 从服务器加载消息列表
*/
private void loadMessagesFromServer() {
String token = AuthStore.getToken(this);
if (token == null || conversationId == null) {
Log.w(TAG, "未登录或会话ID为空");
return;
}
String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + conversationId + "/messages?page=" + currentPage + "&pageSize=" + PAGE_SIZE;
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(() -> parseMessages(body));
}
});
}
private void parseMessages(String body) {
try {
JSONObject json = new JSONObject(body);
if (json.optInt("code", -1) == 200) {
JSONArray data = json.optJSONArray("data");
messages.clear();
String title = binding.titleText.getText() != null ? binding.titleText.getText().toString() : "";
ChatMessage incomingMsg = new ChatMessage(title, "你好~");
incomingMsg.setStatus(ChatMessage.MessageStatus.SENT);
messages.add(incomingMsg);
ChatMessage outgoingMsg = new ChatMessage("", "在的,有什么需要帮忙?");
outgoingMsg.setStatus(ChatMessage.MessageStatus.READ);
messages.add(outgoingMsg);
if (data != null) {
for (int i = 0; i < data.length(); i++) {
JSONObject item = data.getJSONObject(i);
ChatMessage msg = parseChatMessage(item);
if (msg != null) {
messages.add(msg);
}
}
}
// 消息按时间倒序返回需要反转为正序显示
Collections.reverse(messages);
adapter.submitList(new ArrayList<>(messages));
scrollToBottom();
} else {
String msg = json.optString("message", "加载失败");
Snackbar.make(binding.getRoot(), msg, Snackbar.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析消息列表失败", e);
}
}
private ChatMessage parseChatMessage(JSONObject item) {
try {
String messageId = item.optString("messageId", "");
int userId = item.optInt("userId", 0);
String username = item.optString("username", "未知用户");
String message = item.optString("message", "");
long timestamp = item.optLong("timestamp", System.currentTimeMillis());
String status = item.optString("status", "sent");
String avatarUrl = item.optString("avatarUrl", "");
boolean isSystem = item.optBoolean("isSystemMessage", false);
// 判断是否是自己发送的消息
boolean isMine = currentUserId != null && currentUserId.equals(String.valueOf(userId));
String displayName = isMine ? "" : username;
ChatMessage.MessageStatus msgStatus;
switch (status) {
case "sending":
msgStatus = ChatMessage.MessageStatus.SENDING;
break;
case "read":
msgStatus = ChatMessage.MessageStatus.READ;
break;
default:
msgStatus = ChatMessage.MessageStatus.SENT;
}
ChatMessage chatMessage = new ChatMessage(messageId, displayName, message, timestamp, isSystem, msgStatus);
chatMessage.setAvatarUrl(avatarUrl);
return chatMessage;
} catch (Exception e) {
Log.e(TAG, "解析消息失败", e);
return null;
}
}
/**
* 标记会话为已读
*/
private void markConversationAsRead() {
String token = AuthStore.getToken(this);
if (token == null || conversationId == null) return;
String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + conversationId + "/read";
Log.d(TAG, "标记已读: " + url);
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.post(RequestBody.create("", MediaType.parse("application/json")))
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "标记已读失败", e);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String body = response.body() != null ? response.body().string() : "";
Log.d(TAG, "标记已读响应: " + body);
runOnUiThread(() -> {
if (initialUnreadCount > 0) {
UnreadMessageManager.decrementUnreadCount(ConversationActivity.this, initialUnreadCount);
}
});
}
});
}
private void showMessageMenu(ChatMessage message, int position, View anchorView) {
PopupMenu popupMenu = new PopupMenu(this, anchorView);
popupMenu.getMenu().add(0, 0, 0, "复制");
// 只有自己发送的消息才能删除
if ("".equals(message.getUsername())) {
popupMenu.getMenu().add(0, 1, 0, "删除");
}
popupMenu.setOnMenuItemClickListener(item -> {
if (item.getItemId() == 0) {
// 复制消息
copyMessage(message);
return true;
} else if (item.getItemId() == 1) {
// 删除消息
deleteMessage(position);
deleteMessage(message, position);
return true;
}
return false;
@ -154,30 +280,63 @@ public class ConversationActivity extends AppCompatActivity {
Snackbar.make(binding.getRoot(), "已复制到剪贴板", Snackbar.LENGTH_SHORT).show();
}
private void deleteMessage(int position) {
// TODO: 接入后端接口 - 删除消息
// 接口路径: DELETE /api/messages/{messageId}
// 请求参数:
// - messageId: 消息ID路径参数
// - userId: 当前用户ID从token中获取验证是否为消息发送者
// 返回数据格式: ApiResponse<{success: boolean}>
// 删除成功后从本地消息列表移除
private void deleteMessage(ChatMessage message, int position) {
if (position < 0 || position >= messages.size()) return;
new AlertDialog.Builder(this)
.setTitle("删除消息")
.setMessage("确定要删除这条消息吗?")
.setPositiveButton("删除", (dialog, which) -> {
messages.remove(position);
adapter.submitList(new ArrayList<>(messages));
Snackbar.make(binding.getRoot(), "消息已删除", Snackbar.LENGTH_SHORT).show();
})
.setPositiveButton("删除", (dialog, which) -> deleteMessageFromServer(message, position))
.setNegativeButton("取消", null)
.show();
}
private void deleteMessageFromServer(ChatMessage message, int position) {
String token = AuthStore.getToken(this);
if (token == null) return;
String messageId = message.getMessageId();
String url = ApiConfig.getBaseUrl() + "/api/front/conversations/messages/" + messageId;
Log.d(TAG, "删除消息: " + url);
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.delete()
.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) {
messages.remove(position);
adapter.submitList(new ArrayList<>(messages));
Snackbar.make(binding.getRoot(), "消息已删除", Snackbar.LENGTH_SHORT).show();
} else {
Snackbar.make(binding.getRoot(), json.optString("message", "删除失败"), Snackbar.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析删除响应失败", e);
}
});
}
});
}
private void setupInput() {
binding.sendButton.setOnClickListener(new DebounceClickListener(300) { // 发送按钮使用300ms防抖
binding.sendButton.setOnClickListener(new DebounceClickListener(300) {
@Override
public void onDebouncedClick(View v) {
sendMessage();
@ -198,30 +357,18 @@ public class ConversationActivity extends AppCompatActivity {
}
private void sendMessage() {
// 检查登录状态发送私信需要登录
if (!AuthHelper.requireLoginWithToast(this, "发送私信需要登录")) {
return;
}
// TODO: 接入后端接口 - 发送私信消息
// 接口路径: POST /api/conversations/{conversationId}/messages
// 请求参数:
// - conversationId: 会话ID路径参数
// - message: 消息内容
// - userId: 发送者用户ID从token中获取
// 返回数据格式: ApiResponse<ChatMessage>
// ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp, status等字段
// 发送成功后更新消息状态为SENT并添加到消息列表
// TODO: 接入后端接口 - 接收对方已读状态WebSocket或轮询
// 方案1: WebSocket实时推送
// - 连接: ws://api.example.com/conversations/{conversationId}
// - 接收消息格式: {type: "read", messageId: "xxx"} {type: "new_message", data: ChatMessage}
// 方案2: 轮询检查消息状态
// - 接口路径: GET /api/conversations/{conversationId}/messages/{messageId}/status
// - 返回数据格式: ApiResponse<{status: "sent"|"read"}>
String text = binding.messageInput.getText() != null ? binding.messageInput.getText().toString().trim() : "";
if (TextUtils.isEmpty(text)) return;
if (conversationId == null) {
Snackbar.make(binding.getRoot(), "会话ID无效", Snackbar.LENGTH_SHORT).show();
return;
}
// 先在本地显示消息发送中状态
ChatMessage newMessage = new ChatMessage("", text);
newMessage.setStatus(ChatMessage.MessageStatus.SENDING);
messages.add(newMessage);
@ -229,24 +376,72 @@ public class ConversationActivity extends AppCompatActivity {
binding.messageInput.setText("");
scrollToBottom();
// 模拟消息发送过程发送中 -> 已发送 -> 已读
if (statusUpdateRunnable != null) {
handler.removeCallbacks(statusUpdateRunnable);
// 发送到服务器
sendMessageToServer(text, newMessage);
}
statusUpdateRunnable = () -> {
// 1秒后更新为已发送
newMessage.setStatus(ChatMessage.MessageStatus.SENT);
adapter.notifyItemChanged(messages.size() - 1);
private void sendMessageToServer(String text, ChatMessage localMessage) {
String token = AuthStore.getToken(this);
if (token == null) return;
// 再2秒后更新为已读
handler.postDelayed(() -> {
newMessage.setStatus(ChatMessage.MessageStatus.READ);
adapter.notifyItemChanged(messages.size() - 1);
}, 2000);
};
String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + conversationId + "/messages";
Log.d(TAG, "发送消息: " + url);
handler.postDelayed(statusUpdateRunnable, 1000);
try {
JSONObject body = new JSONObject();
body.put("message", text);
body.put("messageType", "text");
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
.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();
// 移除发送失败的消息
messages.remove(localMessage);
adapter.submitList(new ArrayList<>(messages));
});
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String responseBody = response.body() != null ? response.body().string() : "";
Log.d(TAG, "发送消息响应: " + responseBody);
runOnUiThread(() -> handleSendResponse(responseBody, localMessage));
}
});
} catch (Exception e) {
Log.e(TAG, "构建请求失败", e);
}
}
private void handleSendResponse(String responseBody, ChatMessage localMessage) {
try {
JSONObject json = new JSONObject(responseBody);
if (json.optInt("code", -1) == 200) {
JSONObject data = json.optJSONObject("data");
if (data != null) {
// 更新本地消息的ID和状态
localMessage.setMessageId(data.optString("messageId", localMessage.getMessageId()));
localMessage.setStatus(ChatMessage.MessageStatus.SENT);
adapter.notifyItemChanged(messages.indexOf(localMessage));
}
} else {
String msg = json.optString("message", "发送失败");
Snackbar.make(binding.getRoot(), msg, Snackbar.LENGTH_SHORT).show();
messages.remove(localMessage);
adapter.submitList(new ArrayList<>(messages));
}
} catch (Exception e) {
Log.e(TAG, "解析发送响应失败", e);
}
}
private void scrollToBottom() {
@ -262,11 +457,6 @@ public class ConversationActivity extends AppCompatActivity {
@Override
protected void onDestroy() {
super.onDestroy();
// 清理Handler中的延迟任务防止内存泄漏
if (handler != null && statusUpdateRunnable != null) {
handler.removeCallbacks(statusUpdateRunnable);
}
handler = null;
statusUpdateRunnable = null;
}
}

View File

@ -43,8 +43,10 @@ import org.json.JSONObject;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
public class MessagesActivity extends AppCompatActivity {
@ -222,12 +224,13 @@ public class MessagesActivity extends AppCompatActivity {
try {
JSONObject item = data.getJSONObject(i);
String id = String.valueOf(item.opt("id"));
String title = item.optString("otherUserName", "未知用户");
// 后端返回的字段名是 title不是 otherUserName
String title = item.optString("title", item.optString("otherUserName", "未知用户"));
String lastMessage = item.optString("lastMessage", "");
String timeText = item.optString("lastMessageTime", "");
String timeText = item.optString("timeText", item.optString("lastMessageTime", ""));
int unreadCount = item.optInt("unreadCount", 0);
boolean isMuted = item.optBoolean("isMuted", false);
String avatarUrl = item.optString("otherUserAvatar", "");
boolean isMuted = item.optBoolean("muted", item.optBoolean("isMuted", false));
String avatarUrl = item.optString("avatarUrl", item.optString("otherUserAvatar", ""));
ConversationItem convItem = new ConversationItem(id, title, lastMessage, timeText, unreadCount, isMuted);
allConversations.add(convItem);
@ -575,67 +578,116 @@ public class MessagesActivity extends AppCompatActivity {
* 标记会话为已读
*/
private void markAsReadAt(int position) {
// TODO: 接入后端接口 - 标记会话为已读
// 接口路径: POST /api/conversations/{conversationId}/read
// 请求参数:
// - conversationId: 会话ID路径参数
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<{success: boolean}>
// 标记成功后更新本地未读数量为0并更新总未读数量
if (position < 0 || position >= conversations.size()) return;
ConversationItem item = conversations.get(position);
if (item == null) return;
// 获取该会话的未读数量
int unreadCount = item.getUnreadCount();
if (unreadCount <= 0) return;
// 如果已经有未读消息才需要更新
if (unreadCount > 0) {
// 将该会话的未读数量设为0
// updateConversationUnreadCount 方法会自动重新计算总未读数量并更新徽章
updateConversationUnreadCount(item.getId(), 0);
String token = AuthStore.getToken(this);
if (token == null) return;
String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + item.getId() + "/read";
Log.d(TAG, "标记已读: " + url);
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.post(RequestBody.create("", MediaType.parse("application/json")))
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "标记已读失败", e);
runOnUiThread(() -> Toast.makeText(MessagesActivity.this, "标记已读失败", Toast.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) {
updateConversationUnreadCount(item.getId(), 0);
Toast.makeText(MessagesActivity.this, "已标记为已读", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析标记已读响应失败", e);
}
});
}
});
}
/**
* 删除会话
*/
private void deleteConversationAt(int position) {
// TODO: 接入后端接口 - 删除会话
// 接口路径: DELETE /api/conversations/{conversationId}
// 请求参数:
// - conversationId: 会话ID路径参数
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<{success: boolean}>
// 删除成功后从本地列表移除并更新总未读数量
if (position < 0 || position >= conversations.size()) return;
// 获取要删除的会话的未读数量
ConversationItem itemToDelete = conversations.get(position);
int unreadCountToRemove = itemToDelete != null ? itemToDelete.getUnreadCount() : 0;
String itemId = itemToDelete != null ? itemToDelete.getId() : null;
if (itemToDelete == null) return;
// 从当前显示列表和全部列表中删除
conversations.remove(position);
if (itemId != null) {
allConversations.removeIf(item -> item != null && item.getId().equals(itemId));
String token = AuthStore.getToken(this);
if (token == null) return;
String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + itemToDelete.getId();
Log.d(TAG, "删除会话: " + url);
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.delete()
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "删除会话失败", e);
runOnUiThread(() -> Toast.makeText(MessagesActivity.this, "删除失败", Toast.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) {
int unreadCountToRemove = itemToDelete.getUnreadCount();
String itemId = itemToDelete.getId();
conversations.remove(position);
allConversations.removeIf(item -> item != null && item.getId().equals(itemId));
if (conversationsAdapter != null) {
conversationsAdapter.submitList(new ArrayList<>(conversations));
}
// 更新总未读数量减少被删除会话的未读数量
if (unreadCountToRemove > 0) {
UnreadMessageManager.decrementUnreadCount(this, unreadCountToRemove);
// 更新底部导航栏徽章
UnreadMessageManager.decrementUnreadCount(MessagesActivity.this, unreadCountToRemove);
if (binding != null) {
UnreadMessageManager.updateBadge(binding.bottomNavInclude.bottomNavigation);
}
}
updateEmptyState();
Toast.makeText(MessagesActivity.this, "会话已删除", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(MessagesActivity.this, json.optString("message", "删除失败"), Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析删除响应失败", e);
}
});
}
});
}
private float dp(float value) {

View File

@ -336,8 +336,57 @@ public class MyFriendsActivity extends AppCompatActivity {
}
private void openConversation(FriendItem friend) {
// 打开与好友的私聊会话
ConversationActivity.start(this, friend.getId(), friend.getName());
// 先获取或创建与好友的会话再打开私聊页面
String token = AuthStore.getToken(this);
if (token == null) {
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
return;
}
String url = ApiConfig.getBaseUrl() + "/api/front/conversations/with/" + friend.getId();
Log.d(TAG, "获取或创建会话: " + url);
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.post(RequestBody.create("", MediaType.parse("application/json")))
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "获取会话失败", e);
runOnUiThread(() -> {
Toast.makeText(MyFriendsActivity.this, "打开会话失败", Toast.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");
String conversationId = data != null ? data.optString("conversationId", "") : "";
if (!conversationId.isEmpty()) {
ConversationActivity.start(MyFriendsActivity.this, conversationId, friend.getName());
} else {
Toast.makeText(MyFriendsActivity.this, "获取会话ID失败", Toast.LENGTH_SHORT).show();
}
} else {
String msg = json.optString("message", "打开会话失败");
Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析会话响应失败", e);
Toast.makeText(MyFriendsActivity.this, "打开会话失败", Toast.LENGTH_SHORT).show();
}
});
}
});
}
private void applyFilter(String query) {

View File

@ -86,6 +86,15 @@ public class RoomDetailActivity extends AppCompatActivity {
private OkHttpClient wsClient;
private static final String WS_BASE_URL = "ws://192.168.1.164:8081/ws/live/chat/";
// WebSocket 心跳检测
private Runnable heartbeatRunnable;
private Runnable reconnectRunnable;
private static final long HEARTBEAT_INTERVAL = 30000; // 30秒心跳间隔
private static final long RECONNECT_DELAY = 5000; // 5秒重连延迟
private int reconnectAttempts = 0;
private static final int MAX_RECONNECT_ATTEMPTS = 5;
private boolean isWebSocketConnected = false;
// 礼物相关
private BottomSheetDialog giftDialog;
private GiftAdapter giftAdapter;
@ -231,7 +240,12 @@ public class RoomDetailActivity extends AppCompatActivity {
private void connectWebSocket() {
if (TextUtils.isEmpty(roomId)) return;
wsClient = new OkHttpClient();
// 停止之前的重连任务
stopReconnect();
wsClient = new OkHttpClient.Builder()
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS) // OkHttp 内置 ping
.build();
Request request = new Request.Builder()
.url(WS_BASE_URL + roomId)
.build();
@ -240,6 +254,10 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
public void onOpen(WebSocket webSocket, okhttp3.Response response) {
android.util.Log.d("WebSocket", "连接成功: roomId=" + roomId);
isWebSocketConnected = true;
reconnectAttempts = 0;
// 启动心跳检测
startHeartbeat();
}
@Override
@ -261,6 +279,9 @@ public class RoomDetailActivity extends AppCompatActivity {
handler.post(() -> {
addChatMessage(new ChatMessage(content, true));
});
} else if ("pong".equals(type)) {
// 收到心跳响应
android.util.Log.d("WebSocket", "收到心跳响应");
}
} catch (JSONException e) {
android.util.Log.e("WebSocket", "解析消息失败: " + e.getMessage());
@ -270,15 +291,94 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
public void onFailure(WebSocket webSocket, Throwable t, okhttp3.Response response) {
android.util.Log.e("WebSocket", "连接失败: " + t.getMessage());
isWebSocketConnected = false;
stopHeartbeat();
// 尝试重连
scheduleReconnect();
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
android.util.Log.d("WebSocket", "连接关闭: " + reason);
isWebSocketConnected = false;
stopHeartbeat();
// 非正常关闭时尝试重连
if (code != 1000) {
scheduleReconnect();
}
}
});
}
/**
* 启动心跳检测
*/
private void startHeartbeat() {
stopHeartbeat();
heartbeatRunnable = new Runnable() {
@Override
public void run() {
if (webSocket != null && isWebSocketConnected) {
try {
JSONObject ping = new JSONObject();
ping.put("type", "ping");
webSocket.send(ping.toString());
android.util.Log.d("WebSocket", "发送心跳");
} catch (JSONException e) {
android.util.Log.e("WebSocket", "发送心跳失败: " + e.getMessage());
}
handler.postDelayed(heartbeatRunnable, HEARTBEAT_INTERVAL);
}
}
};
handler.postDelayed(heartbeatRunnable, HEARTBEAT_INTERVAL);
}
/**
* 停止心跳检测
*/
private void stopHeartbeat() {
if (heartbeatRunnable != null) {
handler.removeCallbacks(heartbeatRunnable);
heartbeatRunnable = null;
}
}
/**
* 安排重连
*/
private void scheduleReconnect() {
if (isFinishing() || isDestroyed()) return;
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
android.util.Log.w("WebSocket", "达到最大重连次数,停止重连");
handler.post(() -> {
addChatMessage(new ChatMessage("弹幕连接断开,请刷新页面重试", true));
});
return;
}
reconnectAttempts++;
long delay = RECONNECT_DELAY * reconnectAttempts; // 递增延迟
android.util.Log.d("WebSocket", "将在 " + delay + "ms 后尝试第 " + reconnectAttempts + " 次重连");
reconnectRunnable = () -> {
if (!isFinishing() && !isDestroyed()) {
connectWebSocket();
}
};
handler.postDelayed(reconnectRunnable, delay);
}
/**
* 停止重连
*/
private void stopReconnect() {
if (reconnectRunnable != null) {
handler.removeCallbacks(reconnectRunnable);
reconnectRunnable = null;
}
}
private void sendChatViaWebSocket(String content) {
if (webSocket == null) {
// 如果 WebSocket 未连接先本地显示
@ -302,6 +402,10 @@ public class RoomDetailActivity extends AppCompatActivity {
}
private void disconnectWebSocket() {
stopHeartbeat();
stopReconnect();
isWebSocketConnected = false;
if (webSocket != null) {
webSocket.close(1000, "Activity destroyed");
webSocket = null;
@ -784,7 +888,17 @@ public class RoomDetailActivity extends AppCompatActivity {
if (chatSimulationRunnable != null) {
handler.removeCallbacks(chatSimulationRunnable);
}
// 清理心跳和重连回调
if (heartbeatRunnable != null) {
handler.removeCallbacks(heartbeatRunnable);
}
if (reconnectRunnable != null) {
handler.removeCallbacks(reconnectRunnable);
}
}
// 断开 WebSocket 连接
disconnectWebSocket();
// 释放播放器资源
releasePlayer();