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

@@ -6,6 +6,8 @@ import {
api,
} from '@/services/core';
import { liveKitService } from '@/services/livekit';
import { callKeepService } from '@/services/callkeep';
import type { IncomingCallEvent, CallSession as SystemCallSession } from 'expo-callkit-telecom';
import { getCurrentUserId } from '../auth/sessionStore';
import { useUserStore } from '../userStore';
import { userManager } from '../user/UserManager';
@@ -22,14 +24,27 @@ export type CallStatus =
export type CallType = 'voice' | 'video';
export interface CallParticipant {
id: string;
name: string;
avatar?: string | null;
isMuted: boolean;
isVideoEnabled: boolean;
isLocal: boolean;
isReady: boolean;
joinedAt: number;
}
export interface CallSession {
id: string;
conversationId: string;
groupId?: string;
peerId: string;
peerName?: string;
peerAvatar?: string | null;
status: CallStatus;
callType: CallType;
mediaType: 'voice' | 'video';
startedAt?: number;
duration: number;
isMuted: boolean;
@@ -38,6 +53,9 @@ export interface CallSession {
isPeerVideoEnabled: boolean;
isInitiator: boolean;
isPeerReady: boolean;
participants: Map<string, CallParticipant>;
activeSpeakerId: string | null;
pinnedParticipantId: string | null;
}
export interface IncomingCallInfo {
@@ -72,6 +90,19 @@ interface CallState {
toggleMinimize: () => void;
toggleVideo: () => Promise<void>;
setVideoEnabled: (enabled: boolean) => Promise<void>;
handleSystemAnswer: () => Promise<void>;
handleIncomingFromPush: (session: SystemCallSession) => void;
handleSystemMuted: (muted: boolean) => void;
startGroupCall: (
groupId: string,
conversationId: string,
callType: CallType
) => Promise<void>;
addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => void;
removeParticipant: (userId: string) => void;
setActiveSpeaker: (userId: string | null) => void;
pinParticipant: (userId: string | null) => void;
updateParticipantState: (userId: string, updates: Partial<CallParticipant>) => void;
reset: () => void;
}
@@ -99,6 +130,41 @@ function cleanupProcessedCallIds() {
}
}
/**
* Build initial participants map for a new call session.
*/
function buildInitialParticipants(
localUserId: string,
localName: string,
calleeId: string,
calleeName: string,
calleeAvatar?: string | null,
): Map<string, CallParticipant> {
const m = new Map<string, CallParticipant>();
const now = Date.now();
m.set(localUserId, {
id: localUserId,
name: localName,
avatar: null,
isMuted: false,
isVideoEnabled: false,
isLocal: true,
isReady: true,
joinedAt: now,
});
m.set(calleeId, {
id: calleeId,
name: calleeName,
avatar: calleeAvatar ?? null,
isMuted: false,
isVideoEnabled: false,
isLocal: false,
isReady: false,
joinedAt: 0,
});
return m;
}
/**
* Fetch LiveKit token from the backend and connect to the room.
* Connects audio-only first, then enables video separately.
@@ -116,10 +182,13 @@ async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<v
// Store video preference for enabling after connection
pendingVideoEnabled = videoEnabled;
// Configure RTCAudioSession for the call type before connecting
callKeepService.configureAudioForCall(videoEnabled);
setupLiveKitEvents(callId);
// Always connect as audio-only first
await liveKitService.connect(url, token, videoEnabled);
await liveKitService.connect(url, token);
// Enable microphone
await liveKitService.setMuted(false);
@@ -172,6 +241,10 @@ function setupLiveKitEvents(callId: string): void {
// Schedule video enable after settling (avoids iOS deadlock)
enablePendingVideo();
// Sync with system CallKit/Telecom UI
callKeepService.fulfillIncomingCallConnected();
callKeepService.reportOutgoingCallConnected();
})
);
@@ -278,6 +351,67 @@ function setupLiveKitEvents(callId: string): void {
console.error('[CallStore] LiveKit error:', err);
})
);
liveKitUnsubs.push(
liveKitService.on('activeSpeakersChanged', ({ speakers }) => {
const activeId = speakers.length > 0 ? speakers[0].identity : null;
callStore.getState().setActiveSpeaker(activeId);
})
);
liveKitUnsubs.push(
liveKitService.on('participantConnected', ({ participant }) => {
console.log('[CallStore] Remote participant connected:', participant.identity);
callStore.setState((s) => {
if (!s.currentCall) return {};
const newParticipants = new Map(s.currentCall.participants);
if (!newParticipants.has(participant.identity)) {
newParticipants.set(participant.identity, {
id: participant.identity,
name: participant.name || participant.identity,
avatar: null,
isMuted: false,
isVideoEnabled: false,
isLocal: false,
isReady: true,
joinedAt: Date.now(),
});
}
return { currentCall: { ...s.currentCall, participants: newParticipants } };
});
})
);
liveKitUnsubs.push(
liveKitService.on('participantDisconnected', ({ participant }) => {
console.log('[CallStore] Remote participant disconnected:', participant.identity);
callStore.getState().removeParticipant(participant.identity);
})
);
liveKitUnsubs.push(
liveKitService.on('localTrackPublished', ({ publication }) => {
if (publication.source === 'camera') {
callStore.setState((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, isVideoEnabled: true }
: null,
}));
}
})
);
liveKitUnsubs.push(
liveKitService.on('localTrackUnpublished', ({ publication }) => {
if (publication.source === 'camera') {
callStore.setState((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, isVideoEnabled: false }
: null,
}));
}
})
);
}
/**
@@ -308,6 +442,28 @@ export const callStore = create<CallState>((set, get) => ({
initCallUnsub = null;
}
// Initialize CallKit/Telecom service with system UI callbacks
callKeepService.initialize({
onOutgoingStarted: () => {
console.log('[CallStore] System outgoing call started');
},
onSystemAnswered: () => {
console.log('[CallStore] System answered');
get().acceptCall();
},
onSystemEnded: () => {
console.log('[CallStore] System ended call');
get().endCall('system_ended');
},
onSystemMuted: (muted: boolean) => {
get().toggleMute();
},
onIncomingCallFromPush: (_session) => {
// Incoming call from push when app was killed — already handled by WS event
console.log('[CallStore] Incoming call from push (already handled by WS)');
},
});
const unsubs: Array<() => void> = [];
unsubs.push(
@@ -371,14 +527,30 @@ export const callStore = create<CallState>((set, get) => ({
},
});
wsService.preventDisconnectOnBackground = true;
processedCallIds.set(msg.call_id, Date.now());
// Report incoming call to system CallKit/Telecom UI
callKeepService.reportIncomingCallEvent({
eventId: `${msg.call_id}-${Date.now()}`,
serverCallId: msg.call_id,
hasVideo: msg.call_type === 'video',
startedAt: new Date(msg.created_at).toISOString(),
caller: {
id: msg.caller_id,
displayName: callerName,
avatarUrl: caller?.avatar || undefined,
},
metadata: { conversationId: msg.conversation_id },
});
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
callTimeoutTimer = setTimeout(() => {
const { incomingCall: ic } = get();
if (ic?.callId === msg.call_id) {
console.log('[CallStore] Incoming call timeout');
wsService.preventDisconnectOnBackground = false;
wsService.sendCallReject(msg.call_id);
processedCallIds.set(msg.call_id, Date.now());
set({ incomingCall: null });
@@ -450,6 +622,7 @@ export const callStore = create<CallState>((set, get) => ({
if (incomingCall?.callId === msg.call_id) {
console.log('[CallStore] Incoming call cancelled by caller');
wsService.preventDisconnectOnBackground = false;
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
@@ -464,6 +637,7 @@ export const callStore = create<CallState>((set, get) => ({
const { incomingCall } = get();
if (incomingCall?.callId === msg.call_id) {
console.log('[CallStore] Call answered on another device');
wsService.preventDisconnectOnBackground = false;
processedCallIds.set(msg.call_id, Date.now());
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
@@ -506,6 +680,24 @@ export const callStore = create<CallState>((set, get) => ({
})
);
unsubs.push(
wsService.on('call_participant_joined', (msg) => {
console.log('[CallStore] Participant joined:', msg.user_id);
const { currentCall } = get();
if (!currentCall || currentCall.id !== msg.call_id) return;
get().addParticipant(msg.user_id, { name: msg.user_id });
})
);
unsubs.push(
wsService.on('call_participant_left', (msg) => {
console.log('[CallStore] Participant left:', msg.user_id, 'reason:', msg.reason);
const { currentCall } = get();
if (!currentCall || currentCall.id !== msg.call_id) return;
get().removeParticipant(msg.user_id);
})
);
const cleanup = () => {
cleanupResources();
if (unsubInvited) {
@@ -537,10 +729,17 @@ export const callStore = create<CallState>((set, get) => ({
return;
}
wsService.preventDisconnectOnBackground = true;
const cachedCallee = useUserStore.getState().userCache[calleeId];
const callee = calleeInfo || cachedCallee;
const calleeName = callee?.nickname || callee?.username || calleeId;
const localUser = useUserStore.getState().userCache[myUserId];
const localName = localUser?.nickname || localUser?.username || myUserId;
const initialParticipants = buildInitialParticipants(myUserId, localName, calleeId, calleeName, callee?.avatar);
set({
currentCall: {
id: '',
@@ -549,6 +748,7 @@ export const callStore = create<CallState>((set, get) => ({
peerName: calleeName,
peerAvatar: callee?.avatar,
callType,
mediaType: callType,
status: 'calling',
duration: 0,
isMuted: false,
@@ -557,20 +757,38 @@ export const callStore = create<CallState>((set, get) => ({
isPeerVideoEnabled: false,
isInitiator: true,
isPeerReady: false,
participants: initialParticipants,
activeSpeakerId: null,
pinnedParticipantId: null,
},
});
wsService.sendCallInvite(conversationId, calleeId, callType);
callKeepService.startOutgoingCall(calleeId, calleeName, callType === 'video').then((systemCallId) => {
if (systemCallId) {
// Sync system call ID with store, but don't overwrite if WS already set it
const { currentCall: cc } = get();
if (cc && !cc.id) {
set((s) => ({
currentCall: s.currentCall ? { ...s.currentCall, id: systemCallId } : null,
}));
}
}
});
if (unsubInvited) {
unsubInvited();
}
unsubInvited = wsService.on('call_invited', (msg) => {
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, id: msg.call_id }
: null,
}));
set((s) => {
if (!s.currentCall) return {};
// WS call_id is the authoritative one; sync system call ID too
if (s.currentCall.id !== msg.call_id) {
callKeepService.endActiveCall(); // end old system session with wrong ID
}
return { currentCall: { ...s.currentCall, id: msg.call_id } };
});
});
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
@@ -587,6 +805,8 @@ export const callStore = create<CallState>((set, get) => ({
const { incomingCall } = get();
if (!incomingCall) return;
wsService.preventDisconnectOnBackground = true;
console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType);
if (callTimeoutTimer) {
@@ -597,6 +817,33 @@ export const callStore = create<CallState>((set, get) => ({
const isVideoCall = incomingCall.callType === 'video';
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
const myUserId = getCurrentUserId();
const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null;
const localName = localUser?.nickname || localUser?.username || myUserId || 'me';
const participants = new Map<string, CallParticipant>();
const now = Date.now();
participants.set(myUserId || 'me', {
id: myUserId || 'me',
name: localName,
avatar: null,
isMuted: false,
isVideoEnabled: false,
isLocal: true,
isReady: true,
joinedAt: now,
});
participants.set(incomingCall.callerId, {
id: incomingCall.callerId,
name: incomingCall.callerName || incomingCall.callerId,
avatar: incomingCall.callerAvatar ?? null,
isMuted: false,
isVideoEnabled: false,
isLocal: false,
isReady: false,
joinedAt: 0,
});
set({
currentCall: {
id: incomingCall.callId,
@@ -605,6 +852,7 @@ export const callStore = create<CallState>((set, get) => ({
peerName: incomingCall.callerName,
peerAvatar: incomingCall.callerAvatar,
callType: incomingCall.callType as CallType,
mediaType: incomingCall.callType as CallType,
status: 'connecting',
duration: 0,
isMuted: false,
@@ -613,6 +861,9 @@ export const callStore = create<CallState>((set, get) => ({
isPeerVideoEnabled: false,
isInitiator: false,
isPeerReady: false,
participants,
activeSpeakerId: null,
pinnedParticipantId: null,
},
incomingCall: null,
});
@@ -623,6 +874,7 @@ export const callStore = create<CallState>((set, get) => ({
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
} catch (err) {
console.error('[CallStore] Failed to accept call:', err);
callKeepService.failIncomingCallConnected();
get().endCall('connection_failed');
}
},
@@ -631,12 +883,15 @@ export const callStore = create<CallState>((set, get) => ({
const { incomingCall } = get();
if (!incomingCall) return;
wsService.preventDisconnectOnBackground = false;
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
wsService.sendCallReject(incomingCall.callId);
callKeepService.endActiveCall();
set({ incomingCall: null });
},
@@ -644,6 +899,8 @@ export const callStore = create<CallState>((set, get) => ({
const { currentCall } = get();
if (!currentCall) return;
wsService.preventDisconnectOnBackground = false;
cleanupResources();
if (unsubInvited) {
@@ -664,6 +921,13 @@ export const callStore = create<CallState>((set, get) => ({
console.error('[CallStore] Error disconnecting LiveKit:', err);
}
// Sync end with system CallKit/Telecom UI
if (reason === 'ended' || !reason) {
callKeepService.endActiveCall();
} else {
callKeepService.reportCallEndedExternal(reason as any);
}
if (callId && reason !== 'ended') {
wsService.sendCallEnd(callId, reason);
}
@@ -675,6 +939,7 @@ export const callStore = create<CallState>((set, get) => ({
const newMuted = !currentCall.isMuted;
liveKitService.setMuted(newMuted);
callKeepService.setMuted(newMuted);
if (currentCall.id) {
wsService.sendCallMute(currentCall.id, newMuted);
}
@@ -692,6 +957,7 @@ export const callStore = create<CallState>((set, get) => ({
? { ...s.currentCall, isSpeakerOn: newSpeakerOn }
: null,
}));
callKeepService.setAudioOutputToSpeaker(newSpeakerOn);
await liveKitService.setSpeakerOn(newSpeakerOn);
},
@@ -713,6 +979,7 @@ export const callStore = create<CallState>((set, get) => ({
try {
await liveKitService.setVideoEnabled(enabled);
callKeepService.reportVideoEnabled(enabled);
set((s) => ({
currentCall: s.currentCall
@@ -724,6 +991,272 @@ export const callStore = create<CallState>((set, get) => ({
}
},
handleSystemAnswer: async () => {
const { incomingCall, currentCall } = get();
// If already handling a call from the in-app flow, skip
if (currentCall && currentCall.status !== 'idle') return;
if (!incomingCall) return;
wsService.preventDisconnectOnBackground = true;
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
const isVideoCall = incomingCall.callType === 'video';
const myUserId = getCurrentUserId();
const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null;
const localName = localUser?.nickname || localUser?.username || myUserId || 'me';
const sysParticipants = new Map<string, CallParticipant>();
const sysNow = Date.now();
sysParticipants.set(myUserId || 'me', {
id: myUserId || 'me',
name: localName,
avatar: null,
isMuted: false,
isVideoEnabled: false,
isLocal: true,
isReady: true,
joinedAt: sysNow,
});
sysParticipants.set(incomingCall.callerId, {
id: incomingCall.callerId,
name: incomingCall.callerName || incomingCall.callerId,
avatar: incomingCall.callerAvatar ?? null,
isMuted: false,
isVideoEnabled: false,
isLocal: false,
isReady: false,
joinedAt: 0,
});
set({
currentCall: {
id: incomingCall.callId,
conversationId: incomingCall.conversationId,
peerId: incomingCall.callerId,
peerName: incomingCall.callerName,
peerAvatar: incomingCall.callerAvatar,
callType: incomingCall.callType as CallType,
mediaType: incomingCall.callType as CallType,
status: 'connecting',
duration: 0,
isMuted: false,
isSpeakerOn: isVideoCall,
isVideoEnabled: isVideoCall,
isPeerVideoEnabled: false,
isInitiator: false,
isPeerReady: false,
participants: sysParticipants,
activeSpeakerId: null,
pinnedParticipantId: null,
},
incomingCall: null,
});
wsService.sendCallAnswer(incomingCall.callId);
try {
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
} catch (err) {
console.error('[CallStore] handleSystemAnswer failed:', err);
callKeepService.failIncomingCallConnected();
get().endCall('connection_failed');
}
},
handleIncomingFromPush: (session: SystemCallSession) => {
const ie = session.incomingCallEvent;
if (!ie) return;
const { currentCall, incomingCall } = get();
if (currentCall || incomingCall) return;
wsService.preventDisconnectOnBackground = true;
const metadata = ie.metadata ?? {};
const conversationId = (metadata.conversationId as string) || '';
set({
incomingCall: {
callId: ie.serverCallId,
conversationId,
callerId: ie.caller.id,
callerName: ie.caller.displayName || ie.caller.id,
callerAvatar: ie.caller.avatarUrl ?? null,
callType: ie.hasVideo ? 'video' : 'voice',
receivedAt: Date.now(),
lifetime: 55000,
},
});
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
callTimeoutTimer = setTimeout(() => {
const { incomingCall: ic } = get();
if (ic?.callId === ie.serverCallId) {
console.log('[CallStore] Incoming push call timeout');
wsService.preventDisconnectOnBackground = false;
callKeepService.endActiveCall();
set({ incomingCall: null });
}
}, 55000);
},
handleSystemMuted: (muted: boolean) => {
const { currentCall } = get();
if (!currentCall) return;
liveKitService.setMuted(muted);
if (currentCall.id) {
wsService.sendCallMute(currentCall.id, muted);
}
set((s) => ({
currentCall: s.currentCall ? { ...s.currentCall, isMuted: muted } : null,
}));
},
startGroupCall: async (groupId: string, conversationId: string, callType: CallType) => {
const { currentCall } = get();
if (currentCall && currentCall.status !== 'idle') {
console.warn('[CallStore] Already in a call');
return;
}
const myUserId = getCurrentUserId();
if (!myUserId) {
console.error('[CallStore] Not logged in');
return;
}
wsService.preventDisconnectOnBackground = true;
const localUser = useUserStore.getState().userCache[myUserId];
const localName = localUser?.nickname || localUser?.username || myUserId;
const participants = new Map<string, CallParticipant>();
const now = Date.now();
participants.set(myUserId, {
id: myUserId,
name: localName,
avatar: null,
isMuted: false,
isVideoEnabled: false,
isLocal: true,
isReady: true,
joinedAt: now,
});
set({
currentCall: {
id: '',
conversationId,
groupId,
peerId: '',
peerName: undefined,
peerAvatar: null,
callType,
mediaType: callType,
status: 'calling',
duration: 0,
isMuted: false,
isSpeakerOn: callType === 'video',
isVideoEnabled: callType === 'video',
isPeerVideoEnabled: false,
isInitiator: true,
isPeerReady: false,
participants,
activeSpeakerId: null,
pinnedParticipantId: null,
},
});
wsService.sendCallGroupInvite(groupId, conversationId, callType);
if (unsubInvited) unsubInvited();
unsubInvited = wsService.on('call_invited', (msg: any) => {
set((s) => {
if (!s.currentCall) return {};
return { currentCall: { ...s.currentCall, id: msg.call_id } };
});
});
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
callTimeoutTimer = setTimeout(() => {
const { currentCall: cc } = get();
if (cc && cc.status === 'calling') {
console.warn('[CallStore] Group call timeout');
get().endCall('timeout');
}
}, CALL_TIMEOUT_MS);
},
addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => {
const { currentCall } = get();
if (!currentCall) return;
const newParticipants = new Map(currentCall.participants);
if (!newParticipants.has(userId)) {
newParticipants.set(userId, {
id: userId,
name: info.name || userId,
avatar: info.avatar ?? null,
isMuted: false,
isVideoEnabled: false,
isLocal: false,
isReady: true,
joinedAt: Date.now(),
});
} else {
const existing = newParticipants.get(userId)!;
newParticipants.set(userId, {
...existing,
isReady: true,
joinedAt: existing.joinedAt || Date.now(),
});
}
set({ currentCall: { ...currentCall, participants: newParticipants } });
},
removeParticipant: (userId: string) => {
const { currentCall } = get();
if (!currentCall) return;
const newParticipants = new Map(currentCall.participants);
newParticipants.delete(userId);
set({ currentCall: { ...currentCall, participants: newParticipants } });
},
setActiveSpeaker: (userId: string | null) => {
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, activeSpeakerId: userId }
: null,
}));
},
pinParticipant: (userId: string | null) => {
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, pinnedParticipantId: userId }
: null,
}));
},
updateParticipantState: (userId: string, updates: Partial<CallParticipant>) => {
const { currentCall } = get();
if (!currentCall) return;
const p = currentCall.participants.get(userId);
if (!p) return;
const newParticipants = new Map(currentCall.participants);
newParticipants.set(userId, { ...p, ...updates });
set({ currentCall: { ...currentCall, participants: newParticipants } });
},
reset: () => {
cleanupResources();
@@ -738,6 +1271,7 @@ export const callStore = create<CallState>((set, get) => ({
}
liveKitService.dispose();
callKeepService.endActiveCall();
processedCallIds.clear();