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