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

@@ -0,0 +1,284 @@
import { Platform } from 'react-native';
import {
startOutgoingCall,
reportIncomingCall,
answerCall,
endCall,
reportCallEnded,
reportOutgoingCallConnected,
fulfillIncomingCallConnected,
failIncomingCallConnected,
setMuted,
reportVideo,
setAudioSessionPortOverride,
setRTCAudioSessionConfiguration,
addCallSessionAddedListener,
addCallAnsweredListener,
addCallEndedListener,
addOutgoingCallStartedListener,
addSetMutedActionListener,
addVideoChangedListener,
addAudioSessionActivatedListener,
addAudioSessionDeactivatedListener,
addAudioRouteChangedListener,
type CallParticipant,
type IncomingCallEvent,
type CallEndedReason,
type CallSession,
type CallAnsweredEvent,
type CallEndedEvent,
type SetMutedActionEvent,
} from 'expo-callkit-telecom';
const isWeb = Platform.OS === 'web';
export interface CallKeepCallbacks {
onIncomingCallFromPush: (session: CallSession) => void;
onSystemAnswered: () => void;
onSystemEnded: () => void;
onSystemMuted: (muted: boolean) => void;
onOutgoingStarted: () => void;
}
/**
* Thin state-machine adapter between callStore (Zustand) and expo-callkit-telecom.
*
* callStore remains the source of truth for call state.
* This service delegates to the system call API and translates system UI events
* back into callStore actions via registered callbacks.
*/
class CallKeepServiceImpl {
private _activeSystemCallId: string | null = null;
private _pendingRequestId: string | null = null;
private _callbacks: CallKeepCallbacks | null = null;
private _subscriptions: Array<{ remove: () => void }> = [];
private _initialized = false;
// ---- Initialization ----
initialize(callbacks: CallKeepCallbacks): void {
if (isWeb) return;
if (this._initialized) return;
this._callbacks = callbacks;
this._initialized = true;
this._setupListeners();
}
dispose(): void {
this._subscriptions.forEach((s) => s.remove());
this._subscriptions = [];
this._callbacks = null;
this._initialized = false;
}
private _setupListeners(): void {
// Outgoing call accepted by the OS — media should start connecting
this._subscriptions.push(
addOutgoingCallStartedListener(() => {
this._callbacks?.onOutgoingStarted();
}),
);
// Incoming call — a new session was created (from push or reportIncomingCall)
this._subscriptions.push(
addCallSessionAddedListener((event) => {
if (event.session.origin === 'incoming') {
this._activeSystemCallId = event.session.id;
if (!this._pendingRequestId) {
this._callbacks?.onIncomingCallFromPush(event.session);
}
}
}),
);
// User answered incoming call (from system UI or via answerCall)
this._subscriptions.push(
addCallAnsweredListener((event: CallAnsweredEvent) => {
this._activeSystemCallId = event.id;
this._pendingRequestId = event.requestId;
this._callbacks?.onSystemAnswered();
}),
);
// Call ended from system UI (user pressed end in CallKit UI)
this._subscriptions.push(
addCallEndedListener((_event: CallEndedEvent) => {
this._activeSystemCallId = null;
this._pendingRequestId = null;
this._callbacks?.onSystemEnded();
}),
);
// System mute toggle (CallKit mute button)
this._subscriptions.push(
addSetMutedActionListener((event: SetMutedActionEvent) => {
this._callbacks?.onSystemMuted(event.isMuted);
}),
);
// Video changed from system
this._subscriptions.push(
addVideoChangedListener((event) => {
if (this._callbacks && event.hasVideo !== undefined) {
// could add onSystemVideoChanged callback if needed
}
}),
);
// Audio session lifecycle — keep for debugging
this._subscriptions.push(
addAudioSessionActivatedListener(() => {
console.log('[CallKeep] Audio session activated');
}),
);
this._subscriptions.push(
addAudioSessionDeactivatedListener(() => {
console.log('[CallKeep] Audio session deactivated');
}),
);
this._subscriptions.push(
addAudioRouteChangedListener((event) => {
console.log('[CallKeep] Audio route changed:', event.currentRoute);
}),
);
}
// ---- Outgoing Call ----
async startOutgoingCall(
calleeId: string,
calleeName: string,
hasVideo: boolean,
): Promise<string | null> {
if (isWeb) return null;
try {
// End any stale system call before starting a new one
if (this._activeSystemCallId) {
console.warn('[CallKeep] Cleaning up stale system call before new outgoing call');
this.endActiveCall();
}
const recipient: CallParticipant = {
id: calleeId,
displayName: calleeName,
};
const id = await startOutgoingCall(recipient, { hasVideo });
this._activeSystemCallId = id;
console.log('[CallKeep] Outgoing call started:', id);
return id;
} catch (err) {
console.error('[CallKeep] Failed to start outgoing call:', err);
return null;
}
}
reportOutgoingCallConnected(): void {
if (isWeb || !this._activeSystemCallId) return;
reportOutgoingCallConnected(this._activeSystemCallId);
}
// ---- Incoming Call ----
reportIncomingCallEvent(event: IncomingCallEvent): void {
if (isWeb) return;
try {
reportIncomingCall(event);
} catch (err) {
console.error('[CallKeep] Failed to report incoming call:', err);
}
}
answerCallFromApp(): void {
if (isWeb || !this._activeSystemCallId) return;
answerCall(this._activeSystemCallId);
}
fulfillIncomingCallConnected(): void {
if (isWeb || !this._pendingRequestId) return;
try {
fulfillIncomingCallConnected(this._pendingRequestId);
this._pendingRequestId = null;
} catch (err) {
console.error('[CallKeep] Failed to fulfill incoming call:', err);
}
}
failIncomingCallConnected(): void {
if (isWeb || !this._activeSystemCallId || !this._pendingRequestId) return;
try {
failIncomingCallConnected(this._activeSystemCallId, this._pendingRequestId);
this._pendingRequestId = null;
} catch (err) {
console.error('[CallKeep] Failed to fail incoming call:', err);
}
}
// ---- End Call ----
endActiveCall(): void {
if (isWeb || !this._activeSystemCallId) return;
try {
endCall(this._activeSystemCallId);
} catch (err) {
console.error('[CallKeep] Failed to end call:', err);
}
this._activeSystemCallId = null;
this._pendingRequestId = null;
}
reportCallEndedExternal(reason: CallEndedReason): void {
if (isWeb || !this._activeSystemCallId) return;
try {
reportCallEnded(this._activeSystemCallId, reason);
} catch (err) {
console.error('[CallKeep] Failed to report call ended:', err);
}
this._activeSystemCallId = null;
this._pendingRequestId = null;
}
// ---- Audio Session ----
configureAudioForCall(hasVideo: boolean): void {
if (isWeb) return;
setRTCAudioSessionConfiguration(hasVideo);
}
// ---- Controls ----
setMuted(muted: boolean): void {
if (isWeb || !this._activeSystemCallId) return;
setMuted(this._activeSystemCallId, muted);
}
reportVideoEnabled(enabled: boolean): void {
if (isWeb || !this._activeSystemCallId) return;
reportVideo(this._activeSystemCallId, enabled);
}
setAudioOutputToSpeaker(enabled: boolean): void {
if (isWeb) return;
setAudioSessionPortOverride(enabled);
}
// ---- Getters ----
get activeSystemCallId(): string | null {
return this._activeSystemCallId;
}
get pendingRequestId(): string | null {
return this._pendingRequestId;
}
get isWebPlatform(): boolean {
return isWeb;
}
}
export const callKeepService = new CallKeepServiceImpl();

View File

@@ -0,0 +1,2 @@
export { callKeepService } from './callKeepService';
export type { CallKeepCallbacks } from './callKeepService';

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()) {

View File

@@ -1,6 +1,16 @@
import '../../polyfills';
import { Platform } from 'react-native';
import { Room, RoomEvent, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackPublication, Participant as LKParticipant } from 'livekit-client';
import {
Room,
RoomEvent,
Track,
ConnectionState,
LocalParticipant,
RemoteParticipant,
RemoteTrackPublication,
LocalTrackPublication,
TrackPublication,
Participant as LKParticipant,
} from 'livekit-client';
export interface LiveKitServiceConfig {
url: string;
@@ -20,6 +30,11 @@ export interface LiveKitEventMap {
trackUnmuted: { participant: LKParticipant; publication: TrackPublication };
connectionStatusChanged: ConnectionStatus;
error: Error;
activeSpeakersChanged: { speakers: LKParticipant[] };
participantConnected: { participant: RemoteParticipant };
participantDisconnected: { participant: RemoteParticipant };
localTrackPublished: { publication: LocalTrackPublication; participant: LocalParticipant };
localTrackUnpublished: { publication: LocalTrackPublication; participant: LocalParticipant };
}
type EventHandler<K extends keyof LiveKitEventMap> = (data: LiveKitEventMap[K]) => void;
@@ -45,26 +60,11 @@ class LiveKitServiceImpl {
return this.room?.remoteParticipants ?? new Map();
}
async connect(url: string, token: string, isVideoCall: boolean = false): Promise<void> {
async connect(url: string, token: string): Promise<void> {
if (this.room) {
await this.disconnect();
}
// Configure native audio session before connecting (iOS only)
if (Platform.OS === 'ios') {
try {
const { AudioSession } = require('@livekit/react-native');
await AudioSession.configureAudio({
ios: {
defaultOutput: isVideoCall ? 'speaker' : 'default',
},
});
await AudioSession.startAudioSession();
} catch (err) {
console.warn('[LiveKit] Audio session configuration failed:', err);
}
}
this.room = new Room({
adaptiveStream: true,
dynacast: true,
@@ -99,18 +99,16 @@ class LiveKitServiceImpl {
this.room.off(RoomEvent.Reconnecting, this.onReconnecting);
this.room.off(RoomEvent.Reconnected, this.onReconnected);
this.room.off(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
this.room.off(RoomEvent.ActiveSpeakersChanged, this.onActiveSpeakersChanged);
this.room.off(RoomEvent.ParticipantConnected, this.onParticipantConnected);
this.room.off(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected);
this.room.off(RoomEvent.LocalTrackPublished, this.onLocalTrackPublished);
this.room.off(RoomEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished);
await this.room.disconnect();
this.room = null;
this._connectionStatus = 'disconnected';
if (Platform.OS === 'ios') {
try {
const { AudioSession } = require('@livekit/react-native');
await AudioSession.stopAudioSession();
} catch {}
}
this.emit('connectionStatusChanged', 'disconnected');
this.emit('disconnected', undefined);
}
@@ -122,20 +120,60 @@ class LiveKitServiceImpl {
async setVideoEnabled(enabled: boolean): Promise<void> {
if (!this.room?.localParticipant) return;
console.log('[LiveKit] setVideoEnabled called:', enabled);
try {
await this.room.localParticipant.setCameraEnabled(enabled);
console.log('[LiveKit] setVideoEnabled succeeded:', enabled);
} catch (err) {
console.error('[LiveKit] setVideoEnabled failed:', err);
throw err;
await this.room.localParticipant.setCameraEnabled(enabled);
}
async setSpeakerOn(_speakerOn: boolean): Promise<void> {
// Audio routing is handled by expo-callkit-telecom
}
async flipCamera(): Promise<void> {
if (!this.room?.localParticipant) return;
// Camera flip via LiveKit's native support (platform-specific)
const videoTrack = this.room.localParticipant.getTrackPublication(Track.Source.Camera);
if (videoTrack?.track && 'switchCamera' in videoTrack.track) {
await (videoTrack.track as any).switchCamera();
}
}
async setSpeakerOn(speakerOn: boolean): Promise<void> {
if (Platform.OS === 'web' || Platform.OS === 'android') return;
const AudioSession = require('@livekit/react-native').AudioSession;
await AudioSession.selectAudioOutput(speakerOn ? 'force_speaker' : 'default');
async switchMicrophone(deviceId: string): Promise<void> {
if (!this.room) return;
await this.room.switchActiveDevice('audioinput', deviceId);
}
enumerateAudioInputDevices(): Promise<MediaDeviceInfo[]> {
// Platform-specific: delegates to native livekit SDK where available
if (typeof navigator !== 'undefined' && navigator.mediaDevices?.enumerateDevices) {
return navigator.mediaDevices.enumerateDevices().then(d => d.filter(x => x.kind === 'audioinput'));
}
return Promise.resolve([]);
}
enumerateVideoInputDevices(): Promise<MediaDeviceInfo[]> {
if (typeof navigator !== 'undefined' && navigator.mediaDevices?.enumerateDevices) {
return navigator.mediaDevices.enumerateDevices().then(d => d.filter(x => x.kind === 'videoinput'));
}
return Promise.resolve([]);
}
getActiveSpeakers(): LKParticipant[] {
return this.room?.activeSpeakers ?? [];
}
getParticipantTracks(identity: string): {
audio: RemoteTrackPublication | null;
video: RemoteTrackPublication | null;
} {
const participant = this.room?.remoteParticipants.get(identity);
if (!participant) return { audio: null, video: null };
return {
audio: participant.getTrackPublication(Track.Source.Microphone) as RemoteTrackPublication | null,
video: participant.getTrackPublication(Track.Source.Camera) as RemoteTrackPublication | null,
};
}
getParticipantCount(): number {
return (this.room?.remoteParticipants.size ?? 0) + 1; // +1 for local
}
isMuted(): boolean {
@@ -150,22 +188,6 @@ class LiveKitServiceImpl {
return videoTrack?.isSubscribed ?? false;
}
getRemoteAudioTrack(): Track | null {
for (const [, participant] of this.remoteParticipants) {
const audioPub = participant.getTrackPublication(Track.Source.Microphone);
if (audioPub?.track) return audioPub.track;
}
return null;
}
getRemoteVideoTrack(): Track | null {
for (const [, participant] of this.remoteParticipants) {
const videoPub = participant.getTrackPublication(Track.Source.Camera);
if (videoPub?.track) return videoPub.track;
}
return null;
}
on<K extends keyof LiveKitEventMap>(event: K, handler: EventHandler<K>): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
@@ -192,6 +214,11 @@ class LiveKitServiceImpl {
this.room.on(RoomEvent.Reconnecting, this.onReconnecting);
this.room.on(RoomEvent.Reconnected, this.onReconnected);
this.room.on(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
this.room.on(RoomEvent.ActiveSpeakersChanged, this.onActiveSpeakersChanged);
this.room.on(RoomEvent.ParticipantConnected, this.onParticipantConnected);
this.room.on(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected);
this.room.on(RoomEvent.LocalTrackPublished, this.onLocalTrackPublished);
this.room.on(RoomEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished);
}
private onTrackSubscribed = (track: Track, publication: RemoteTrackPublication, participant: RemoteParticipant): void => {
@@ -240,6 +267,26 @@ class LiveKitServiceImpl {
this.emit('connectionStatusChanged', status);
};
private onActiveSpeakersChanged = (speakers: LKParticipant[]): void => {
this.emit('activeSpeakersChanged', { speakers });
};
private onParticipantConnected = (participant: RemoteParticipant): void => {
this.emit('participantConnected', { participant });
};
private onParticipantDisconnected = (participant: RemoteParticipant): void => {
this.emit('participantDisconnected', { participant });
};
private onLocalTrackPublished = (publication: LocalTrackPublication, participant: LocalParticipant): void => {
this.emit('localTrackPublished', { publication, participant });
};
private onLocalTrackUnpublished = (publication: LocalTrackPublication, participant: LocalParticipant): void => {
this.emit('localTrackUnpublished', { publication, participant });
};
private emit<K extends keyof LiveKitEventMap>(event: K, data: LiveKitEventMap[K]): void {
const handlers = this.handlers.get(event);
if (handlers) {
@@ -255,4 +302,4 @@ class LiveKitServiceImpl {
}
export const liveKitService = new LiveKitServiceImpl();
export default liveKitService;
export default liveKitService;