feat(call): implement CallKeep integration and enhance group call support
Some checks failed
Frontend CI / ota-android (push) Successful in 1m27s
Frontend CI / build-and-push-web (push) Failing after 1m28s
Frontend CI / ota-ios (push) Successful in 2m58s
Frontend CI / build-android-apk (push) Successful in 51m46s

Integrate `expo-callkit-telecom` to support native system call handling (answering, ending, and muting via system UI). This includes a new `callKeepService` and a `CallKeepBootstrap` component to manage the lifecycle of system call events.

Additionally, improves the calling experience by:
- Adding support for group calls with participant tracking and UI indicators.
- Enhancing LiveKit integration by replacing polling with event-driven video track synchronization.
- Updating `WebSocketService` to prevent disconnection when the app enters the background during an active call.
- Adding new WebSocket message types for participant join/leave events and group invites.
- Refining call UI components (`CallScreen`, `FloatingCallWindow`, `IncomingCallModal`) for better visual feedback and safe area handling.

Refactor LiveKit service to use event-driven updates for local and remote tracks, improving performance and reliability.
This commit is contained in:
2026-06-03 01:56:02 +08:00
parent d6a94c7b4d
commit 2e6912dddf
18 changed files with 1635 additions and 507 deletions

View File

@@ -164,7 +164,7 @@ class ApiClient {
// 清除 token
async clearToken(): Promise<void> {
try {
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
await AsyncStorage.removeMany([TOKEN_KEY, REFRESH_TOKEN_KEY]);
} catch (error) {
console.error('清除token失败:', error);
}

View File

@@ -152,7 +152,7 @@ export class CacheDataSource implements ICacheDataSource {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix));
if (cacheKeys.length > 0) {
await AsyncStorage.multiRemove(cacheKeys);
await AsyncStorage.removeMany(cacheKeys);
}
} catch (error) {
throw new DataSourceError(

View File

@@ -44,6 +44,8 @@ export type WSMessageType =
| 'call_peer_muted'
| 'call_invited'
| 'call_answered_elsewhere'
| 'call_participant_joined'
| 'call_participant_left'
| 'sync_required';
export interface WSCallIncomingMessage {
@@ -103,6 +105,19 @@ export interface WSCallAnsweredElsewhereMessage {
reason?: string;
}
export interface WSCallParticipantJoinedMessage {
type: 'call_participant_joined';
call_id: string;
user_id: string;
}
export interface WSCallParticipantLeftMessage {
type: 'call_participant_left';
call_id: string;
user_id: string;
reason?: string;
}
export interface WSErrorMessage {
type: 'error';
code: string;
@@ -257,6 +272,8 @@ export type WSMessage =
| WSCallPeerMutedMessage
| WSCallInvitedMessage
| WSCallAnsweredElsewhereMessage
| WSCallParticipantJoinedMessage
| WSCallParticipantLeftMessage
| WSErrorMessage
| WSSyncRequiredMessage;
@@ -300,6 +317,9 @@ class WebSocketService {
private isFallbackMode = false;
private fallbackCheckTimer: ReturnType<typeof setTimeout> | null = null;
// 通话中阻止后台断开(对方请求权限等场景会导致 AppState 切换,不应断开 WebSocket
preventDisconnectOnBackground = false;
private getWSUrl(token: string | null): string {
return `${WS_URL}?token=${encodeURIComponent(token || '')}&compress=1`;
}
@@ -747,6 +767,14 @@ class WebSocketService {
this.sendFireAndForget('call_ready', { call_id: callId });
}
sendCallGroupInvite(groupId: string, conversationId: string, callType: string): void {
this.sendFireAndForget('call_group_invite', {
group_id: groupId,
conversation_id: conversationId,
call_type: callType,
});
}
private handleMessageSent(payload: any): void {
// 找到对应的发送请求并resolve
const pendingMsg = this.pendingMessages.find(p =>
@@ -1059,9 +1087,14 @@ class WebSocketService {
this.lastAppState = AppState.currentState;
this.appStateSubscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
// 进入后台时断开 WebSocket由 JPush 负责后台推送通知
// 但如果正在通话中则不断开,避免对方请求权限等场景导致通话中断
if (this.lastAppState === 'active' && nextState.match(/inactive|background/)) {
console.log('[WebSocket] App entering background, disconnecting');
this.disconnect();
if (this.preventDisconnectOnBackground) {
console.log('[WebSocket] App entering background but call is active, skipping disconnect');
} else {
console.log('[WebSocket] App entering background, disconnecting');
this.disconnect();
}
}
// 回到前台时重连 WebSocket
if (this.lastAppState.match(/inactive|background/) && nextState === 'active' && !this.isConnected()) {