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