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