feat(CallFeature): implement call functionality and integrate WebSocket signaling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m2s
Frontend CI / ota-android (push) Successful in 11m28s
Frontend CI / build-android-apk (push) Successful in 1h0m58s

- Updated app.json to include microphone permissions and background audio support.
- Added call-related dependencies in package.json and package-lock.json.
- Enhanced ChatScreen to initiate calls and handle call actions.
- Introduced call components in the layout for incoming call handling.
- Expanded WebSocket service to manage call signaling messages and events.
- Refactored authStore to initialize call state on user authentication.
This commit is contained in:
lafay
2026-03-27 17:12:19 +08:00
parent db7885086f
commit b19a2ced6f
18 changed files with 2086 additions and 18 deletions

View File

@@ -0,0 +1,281 @@
import {
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate,
mediaDevices,
MediaStream,
MediaStreamTrack,
} from 'react-native-webrtc';
export interface ICEServer {
urls: string[];
username?: string;
credential?: string;
}
export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
export type WebRTCManagerEvent =
| { type: 'icecandidate'; candidate: RTCIceCandidate | null }
| { type: 'connectionstatechange'; state: ConnectionState }
| { type: 'remotestream'; stream: MediaStream }
| { type: 'error'; error: Error };
type EventHandler = (event: WebRTCManagerEvent) => void;
class WebRTCManager {
private peerConnection: RTCPeerConnection | null = null;
private localStream: MediaStream | null = null;
private remoteStream: MediaStream | null = null;
private pendingCandidates: RTCIceCandidateInit[] = [];
private iceServers: ICEServer[] = [];
private eventHandlers: Set<EventHandler> = new Set();
private disposed = false;
private isInitiator = false;
async initialize(iceServers: ICEServer[] = []): Promise<void> {
if (this.peerConnection) {
this.dispose();
}
this.disposed = false;
this.iceServers = iceServers.length > 0
? iceServers
: [{ urls: ['stun:stun.l.google.com:19302'] }];
}
private buildPeerConnectionConfig(): RTCConfiguration {
return {
iceServers: this.iceServers.map((server) => ({
urls: server.urls,
...(server.username ? { username: server.username } : {}),
...(server.credential ? { credential: server.credential } : {}),
})),
iceCandidatePoolSize: 10,
};
}
private createPeerConnection(): RTCPeerConnection {
const config = this.buildPeerConnectionConfig();
const pc = new RTCPeerConnection(config);
// @ts-ignore - react-native-webrtc uses on* handlers instead of addEventListener
pc.onicecandidate = (event) => {
if (event.candidate) {
this.emit({ type: 'icecandidate', candidate: event.candidate });
} else {
this.emit({ type: 'icecandidate', candidate: null });
}
};
// @ts-ignore
pc.oniceconnectionstatechange = () => {
const state = pc.iceConnectionState as ConnectionState;
this.emit({ type: 'connectionstatechange', state });
};
// @ts-ignore
pc.onconnectionstatechange = () => {
const state = pc.connectionState as ConnectionState;
this.emit({ type: 'connectionstatechange', state });
};
// @ts-ignore
pc.ontrack = (event) => {
if (event.streams && event.streams[0]) {
this.remoteStream = event.streams[0];
this.emit({ type: 'remotestream', stream: event.streams[0] });
}
};
// @ts-ignore
pc.onsignalingstatechange = () => {
// Could emit state change here if needed
};
return pc;
}
async createLocalStream(voiceOnly = true): Promise<MediaStream> {
const constraints = voiceOnly
? { audio: true, video: false }
: { audio: true, video: { facingMode: 'user', frameRate: 30 } };
try {
// @ts-ignore - react-native-webrtc has different constraint types
this.localStream = await mediaDevices.getUserMedia(constraints);
return this.localStream;
} catch (error) {
console.error('[WebRTC] Failed to get local media stream:', error);
throw error;
}
}
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
if (this.disposed) throw new Error('WebRTCManager has been disposed');
if (!this.localStream) throw new Error('Local stream not initialized');
this.isInitiator = isInitiator;
this.peerConnection = this.createPeerConnection();
// Add local tracks to peer connection
for (const track of this.localStream.getTracks()) {
this.peerConnection.addTrack(track, this.localStream);
}
if (isInitiator) {
const offer = await this.createOffer();
return offer;
}
return null;
}
async createOffer(): Promise<RTCSessionDescriptionInit> {
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
const offerOptions = {
offerToReceiveAudio: true,
offerToReceiveVideo: false,
};
const offer = await this.peerConnection.createOffer(offerOptions);
await this.peerConnection.setLocalDescription(offer);
return offer;
}
async createAnswer(): Promise<RTCSessionDescriptionInit> {
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
// Process pending candidates after local description is set
await this.processPendingCandidates();
return answer;
}
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
await this.setRemoteDescription(offer);
return this.createAnswer();
}
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
if (!description.sdp) {
throw new Error('setRemoteDescription: sdp is required');
}
const desc = new RTCSessionDescription({
type: description.type,
sdp: description.sdp,
});
await this.peerConnection.setRemoteDescription(desc);
// Process pending candidates after remote description is set
await this.processPendingCandidates();
}
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
if (!this.peerConnection) {
this.pendingCandidates.push(candidate);
return;
}
if (!this.peerConnection.remoteDescription) {
this.pendingCandidates.push(candidate);
return;
}
try {
const iceCandidate = new RTCIceCandidate(candidate);
await this.peerConnection.addIceCandidate(iceCandidate);
} catch (error) {
console.error('[WebRTC] Failed to add ICE candidate:', error);
// Still push to pending in case order matters
this.pendingCandidates.push(candidate);
}
}
private async processPendingCandidates(): Promise<void> {
if (this.pendingCandidates.length === 0) return;
if (!this.peerConnection) return;
const candidates = [...this.pendingCandidates];
this.pendingCandidates = [];
for (const candidate of candidates) {
try {
const iceCandidate = new RTCIceCandidate(candidate);
await this.peerConnection.addIceCandidate(iceCandidate);
} catch (error) {
console.error('[WebRTC] Failed to add pending ICE candidate:', error);
}
}
}
setMuted(muted: boolean): void {
if (!this.localStream) return;
const audioTracks = this.localStream.getAudioTracks();
audioTracks.forEach((track) => {
track.enabled = !muted;
});
}
isMuted(): boolean {
if (!this.localStream) return false;
const audioTracks = this.localStream.getAudioTracks();
return audioTracks.some((track) => !track.enabled);
}
getRemoteStream(): MediaStream | null {
return this.remoteStream;
}
getLocalStream(): MediaStream | null {
return this.localStream;
}
getPeerConnection(): RTCPeerConnection | null {
return this.peerConnection;
}
onEvent(handler: EventHandler): () => void {
this.eventHandlers.add(handler);
return () => {
this.eventHandlers.delete(handler);
};
}
private emit(event: WebRTCManagerEvent): void {
this.eventHandlers.forEach((handler) => {
try {
handler(event);
} catch (error) {
console.error('[WebRTC] Event handler error:', error);
}
});
}
dispose(): void {
this.disposed = true;
this.eventHandlers.clear();
this.pendingCandidates = [];
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop());
this.localStream.release();
this.localStream = null;
}
if (this.remoteStream) {
this.remoteStream.getTracks().forEach((track) => track.stop());
this.remoteStream.release();
this.remoteStream = null;
}
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
}
}
export const webrtcManager = new WebRTCManager();

View File

@@ -0,0 +1,2 @@
export { webrtcManager } from './WebRTCManager';
export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager';

View File

@@ -30,7 +30,106 @@ export type WSMessageType =
| 'pong'
| 'message_sent'
| 'message_recalled'
| 'error';
| 'error'
| 'call_incoming'
| 'call_accepted'
| 'call_rejected'
| 'call_busy'
| 'call_sdp'
| 'call_ice'
| 'call_ended'
| 'call_peer_muted'
| 'call_invited'
| 'call_answered_elsewhere';
export interface WSCallIncomingMessage {
type: 'call_incoming';
call_id: string;
conversation_id: string;
caller_id: string;
call_type: string;
created_at: number;
lifetime?: number;
ice_servers?: ICEServerConfig[];
}
export interface ICEServerConfig {
urls: string[];
username?: string;
credential?: string;
}
export interface WSCallAcceptedMessage {
type: 'call_accepted';
call_id: string;
started_at: number;
ice_servers?: ICEServerConfig[];
}
export interface WSCallRejectedMessage {
type: 'call_rejected';
call_id: string;
reason?: string;
}
export interface WSCallBusyMessage {
type: 'call_busy';
call_id: string;
}
export interface WSCallEndedMessage {
type: 'call_ended';
call_id: string;
ended_by?: string;
reason?: string;
duration?: number;
ended_at?: number;
}
export interface WSCallSDPMessage {
type: 'call_sdp';
call_id: string;
from_id: string;
payload: {
sdp_type: string;
sdp: string;
};
}
export interface WSCallICEMessage {
type: 'call_ice';
call_id: string;
from_id: string;
payload: {
candidate: string;
};
}
export interface WSCallPeerMutedMessage {
type: 'call_peer_muted';
call_id: string;
user_id: string;
muted: boolean;
}
export interface WSCallInvitedMessage {
type: 'call_invited';
call_id: string;
conversation_id: string;
callee_id: string;
}
export interface WSCallAnsweredElsewhereMessage {
type: 'call_answered_elsewhere';
call_id: string;
reason?: string;
}
export interface WSErrorMessage {
type: 'error';
code: string;
message: string;
}
export interface WSChatMessage {
type: 'chat';
@@ -166,7 +265,18 @@ export type WSMessage =
| WSGroupNoticeMessage
| WSGroupMentionMessage
| WSGroupReadMessage
| WSGroupRecallMessage;
| WSGroupRecallMessage
| WSCallIncomingMessage
| WSCallAcceptedMessage
| WSCallRejectedMessage
| WSCallBusyMessage
| WSCallEndedMessage
| WSCallSDPMessage
| WSCallICEMessage
| WSCallPeerMutedMessage
| WSCallInvitedMessage
| WSCallAnsweredElsewhereMessage
| WSErrorMessage;
type MessageHandler<T extends WSMessage = WSMessage> = (message: T) => void;
type ConnectionHandler = () => void;
@@ -273,17 +383,35 @@ class WebSocketService {
}
private handleIncoming(data: string): void {
try {
const msg: WSServerMessage = JSON.parse(data);
// 后端 writePump 会将多条消息以 \n 分隔合并发送,需要逐条解析
const lines = data.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg: WSServerMessage = JSON.parse(trimmed);
this.processMessage(msg);
} catch (error) {
console.error('Failed to parse WebSocket message:', error, 'raw:', trimmed);
}
}
}
private processMessage(msg: WSServerMessage): void {
// 处理pong响应
if (msg.type === 'pong') {
return;
}
// 处理错误消息
// 处理错误消息 - emit 到上层处理
if (msg.type === 'error') {
console.error('WebSocket error:', msg.payload);
const err: WSErrorMessage = {
type: 'error',
code: msg.payload?.code || 'unknown',
message: msg.payload?.message || 'Unknown error',
};
this.emit('error', err);
return;
}
@@ -306,9 +434,6 @@ class WebSocketService {
// 分发事件
this.dispatchServerMessage(msg);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
}
private dispatchServerMessage(msg: WSServerMessage): void {
@@ -333,6 +458,37 @@ class WebSocketService {
case 'system_notification':
this.handleSystemNotification(payload);
break;
// Call signaling messages
case 'call_incoming':
this.handleCallIncoming(payload);
break;
case 'call_accepted':
this.handleCallAccepted(payload);
break;
case 'call_rejected':
this.handleCallRejected(payload);
break;
case 'call_busy':
this.handleCallBusy(payload);
break;
case 'call_sdp':
this.handleCallSDP(payload);
break;
case 'call_ice':
this.handleCallICE(payload);
break;
case 'call_ended':
this.handleCallEnded(payload);
break;
case 'call_peer_muted':
this.handleCallPeerMuted(payload);
break;
case 'call_invited':
this.handleCallInvited(payload);
break;
case 'call_answered_elsewhere':
this.handleCallAnsweredElsewhere(payload);
break;
default:
console.log('Unknown message type:', type);
}
@@ -458,6 +614,152 @@ class WebSocketService {
systemNotificationService.handleWSMessage(m as any).catch(() => {});
}
// Call signaling handlers
private handleCallIncoming(payload: any): void {
const m: WSCallIncomingMessage = {
type: 'call_incoming',
call_id: payload.call_id,
conversation_id: payload.conversation_id,
caller_id: payload.caller_id,
call_type: payload.call_type,
created_at: payload.created_at,
lifetime: payload.lifetime,
ice_servers: payload.ice_servers,
};
this.emit('call_incoming', m);
}
private handleCallAccepted(payload: any): void {
const m: WSCallAcceptedMessage = {
type: 'call_accepted',
call_id: payload.call_id,
started_at: payload.started_at,
ice_servers: payload.ice_servers,
};
this.emit('call_accepted', m);
}
private handleCallRejected(payload: any): void {
const m: WSCallRejectedMessage = {
type: 'call_rejected',
call_id: payload.call_id,
reason: payload.reason,
};
this.emit('call_rejected', m);
}
private handleCallBusy(payload: any): void {
const m: WSCallBusyMessage = {
type: 'call_busy',
call_id: payload.call_id,
};
this.emit('call_busy', m);
}
private handleCallSDP(payload: any): void {
const m: WSCallSDPMessage = {
type: 'call_sdp',
call_id: payload.call_id,
from_id: payload.from_id,
payload: payload.payload,
};
this.emit('call_sdp', m);
}
private handleCallICE(payload: any): void {
const m: WSCallICEMessage = {
type: 'call_ice',
call_id: payload.call_id,
from_id: payload.from_id,
payload: payload.payload,
};
this.emit('call_ice', m);
}
private handleCallEnded(payload: any): void {
const m: WSCallEndedMessage = {
type: 'call_ended',
call_id: payload.call_id,
ended_by: payload.ended_by,
reason: payload.reason,
duration: payload.duration,
ended_at: payload.ended_at,
};
this.emit('call_ended', m);
}
private handleCallPeerMuted(payload: any): void {
const m: WSCallPeerMutedMessage = {
type: 'call_peer_muted',
call_id: payload.call_id,
user_id: payload.user_id,
muted: payload.muted,
};
this.emit('call_peer_muted', m);
}
private handleCallInvited(payload: any): void {
const m: WSCallInvitedMessage = {
type: 'call_invited',
call_id: payload.call_id,
conversation_id: payload.conversation_id,
callee_id: payload.callee_id,
};
this.emit('call_invited', m);
}
private handleCallAnsweredElsewhere(payload: any): void {
const m: WSCallAnsweredElsewhereMessage = {
type: 'call_answered_elsewhere',
call_id: payload.call_id,
reason: payload.reason,
};
this.emit('call_answered_elsewhere', m);
}
// Call signaling send methods
sendCallInvite(conversationId: string, calleeId: string): void {
this.sendFireAndForget('call_invite', {
conversation_id: conversationId,
callee_id: calleeId,
});
}
sendCallAnswer(callId: string): void {
this.sendFireAndForget('call_answer', { call_id: callId });
}
sendCallReject(callId: string): void {
this.sendFireAndForget('call_reject', { call_id: callId });
}
sendCallBusy(callId: string): void {
this.sendFireAndForget('call_busy', { call_id: callId });
}
sendCallSDP(callId: string, sdpType: string, sdp: string): void {
this.sendFireAndForget('call_sdp', {
call_id: callId,
sdp_type: sdpType,
sdp,
});
}
sendCallICE(callId: string, candidate: string): void {
this.sendFireAndForget('call_ice', {
call_id: callId,
candidate,
});
}
sendCallEnd(callId: string, reason?: string): void {
this.sendFireAndForget('call_end', { call_id: callId, reason });
}
sendCallMute(callId: string, muted: boolean): void {
this.sendFireAndForget('call_mute', { call_id: callId, muted });
}
private handleMessageSent(payload: any): void {
// 找到对应的发送请求并resolve
const pendingMsg = this.pendingMessages.find(p =>
@@ -540,6 +842,15 @@ class WebSocketService {
return api.post('/messages/delete_msg', { message_id: messageId });
}
// 通过WebSocket发送fire-and-forget不等待确认
private sendFireAndForget(type: string, payload: any): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
const message = { type, payload };
this.ws.send(JSON.stringify(message));
}
// 通过WebSocket发送
private sendViaWebSocket(type: string, payload: any): Promise<any> {
return new Promise((resolve, reject) => {