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

@@ -40,10 +40,27 @@ const CallScreen: React.FC = () => {
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
// Subscribe to LiveKit track events
// Event-driven video track subscription (no polling)
useEffect(() => {
const unsubs: (() => void)[] = [];
// Sync current tracks immediately
const syncTracks = () => {
const lp = liveKitService.localParticipant;
if (lp) {
const videoPub = lp.getTrackPublication(Track.Source.Camera);
setLocalVideoTrack((videoPub?.track as VideoTrack | undefined) ?? null);
}
liveKitService.remoteParticipants.forEach((participant) => {
const videoPub = participant.getTrackPublication(Track.Source.Camera);
if (videoPub?.track) {
setRemoteVideoTrack(videoPub.track as VideoTrack);
}
});
};
syncTracks();
// Remote video track events
unsubs.push(
liveKitService.on('trackSubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
@@ -69,28 +86,32 @@ const CallScreen: React.FC = () => {
);
unsubs.push(
liveKitService.on('trackUnmuted', ({ publication, participant }) => {
liveKitService.on('trackUnmuted', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setRemoteVideoTrack(publication.track as VideoTrack);
}
})
);
// Poll local video track from localParticipant
const localPollInterval = setInterval(() => {
const lp = liveKitService.localParticipant;
if (lp) {
const videoPub = lp.getTrackPublication(Track.Source.Camera);
const track = (videoPub?.track ?? null) as VideoTrack | null;
setLocalVideoTrack((prev) => (prev === track ? prev : track));
} else {
setLocalVideoTrack((prev) => (prev === null ? prev : null));
}
}, 500);
// Local video track events (replace 500ms polling)
unsubs.push(
liveKitService.on('localTrackPublished', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setLocalVideoTrack(publication.track as VideoTrack);
}
})
);
unsubs.push(
liveKitService.on('localTrackUnpublished', ({ publication }) => {
if (publication.source === Track.Source.Camera) {
setLocalVideoTrack(null);
}
})
);
return () => {
unsubs.forEach((unsub) => unsub());
clearInterval(localPollInterval);
};
}, []);

View File

@@ -6,9 +6,12 @@ import {
TouchableOpacity,
Image,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { MaterialCommunityIcons, Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { callStore } from '../../stores/call';
const BUTTON_SIZE = 40;
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
@@ -22,69 +25,86 @@ const FloatingCallWindow: React.FC = () => {
const toggleMinimize = callStore((s) => s.toggleMinimize);
const endCall = callStore((s) => s.endCall);
// Only show when there's an active call and it's minimized
const insets = useSafeAreaInsets();
if (!currentCall || !isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
return '等待接听...';
case 'ringing':
return '来电响铃...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'connecting':
case 'calling':
case 'ringing':
return '连接中...';
case 'reconnecting':
return '重连中...';
case 'ended':
return '已结束';
case 'failed':
return '连接失败';
default:
return '';
}
};
const isConnected = currentCall.status === 'connected';
const participantCount = currentCall.participants.size;
const isVideoCall = currentCall.mediaType === 'video';
const isGroupCall = participantCount > 2;
const displayName = isGroupCall
? `群组通话 (${participantCount})`
: currentCall.peerName || '未知用户';
return (
<View style={styles.container}>
<View style={[styles.container, { top: insets.top + 4 }]}>
<TouchableOpacity
style={styles.window}
style={styles.bar}
onPress={toggleMinimize}
activeOpacity={0.9}
>
{/* Avatar */}
<View style={styles.avatarContainer}>
{currentCall.peerAvatar ? (
<Image
source={{ uri: currentCall.peerAvatar }}
style={styles.avatar}
/>
{/* Call type indicator */}
<View style={styles.callTypeIcon}>
<Ionicons
name={isVideoCall ? 'videocam' : 'call'}
size={14}
color={isConnected ? '#34C759' : 'rgba(255,255,255,0.6)'}
/>
</View>
{/* Avatar + status indicator ring */}
<View style={styles.avatarWrap}>
{currentCall.peerAvatar && !isGroupCall ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
<View style={[styles.avatar, styles.avatarFallback]}>
{isGroupCall ? (
<MaterialCommunityIcons name="account-group" size={22} color="#fff" />
) : (
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
)}
</View>
)}
{isConnected && <View style={styles.statusDot} />}
</View>
{/* Info */}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
{displayName}
</Text>
<Text style={[styles.status, isConnected && styles.statusTimer]}>
{getStatusText()}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* End call button */}
<TouchableOpacity
style={styles.endButton}
style={styles.endBtn}
onPress={(e) => {
e.stopPropagation();
endCall('hangup');
}}
activeOpacity={0.7}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
</TouchableOpacity>
@@ -96,25 +116,34 @@ const FloatingCallWindow: React.FC = () => {
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 100,
left: 16,
right: 16,
left: 0,
right: 0,
zIndex: 10000,
paddingHorizontal: 16,
},
window: {
bar: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#2A2A4E',
borderRadius: 16,
padding: 12,
backgroundColor: 'rgba(20, 20, 40, 0.92)',
borderRadius: 18,
paddingVertical: 12,
paddingHorizontal: 14,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
shadowOpacity: 0.35,
shadowRadius: 12,
elevation: 10,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.08)',
},
avatarContainer: {
marginRight: 12,
callTypeIcon: {
marginRight: 8,
width: 20,
alignItems: 'center',
},
avatarWrap: {
position: 'relative',
marginRight: 14,
},
avatar: {
width: 44,
@@ -122,7 +151,7 @@ const styles = StyleSheet.create({
borderRadius: 22,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
avatarFallback: {
justifyContent: 'center',
alignItems: 'center',
},
@@ -131,28 +160,44 @@ const styles = StyleSheet.create({
color: '#fff',
fontWeight: '600',
},
statusDot: {
position: 'absolute',
bottom: 0,
right: 0,
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#34C759',
borderWidth: 2,
borderColor: 'rgba(20, 20, 40, 0.92)',
},
info: {
flex: 1,
justifyContent: 'center',
},
name: {
fontSize: 15,
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 2,
},
status: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
color: 'rgba(255, 255, 255, 0.45)',
},
endButton: {
width: 36,
height: 36,
borderRadius: 18,
statusTimer: {
color: '#34C759',
fontWeight: '500',
fontVariant: ['tabular-nums'],
},
endBtn: {
width: BUTTON_SIZE,
height: BUTTON_SIZE,
borderRadius: BUTTON_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
marginLeft: 10,
},
});

View File

@@ -8,160 +8,190 @@ import {
Animated,
Modal,
StatusBar,
Dimensions,
Platform,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { callStore } from '../../stores/call';
import { blurActiveElement } from '../../infrastructure/platform';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const AVATAR_SIZE = 104;
const BTN_SIZE = 68;
const BTN_ICON = 30;
const IncomingCallModal: React.FC = () => {
const incomingCall = callStore((s) => s.incomingCall);
const acceptCall = callStore((s) => s.acceptCall);
const rejectCall = callStore((s) => s.rejectCall);
const insets = useSafeAreaInsets();
const pulseAnim = useRef(new Animated.Value(1)).current;
const rippleAnim = useRef(new Animated.Value(0)).current;
const rippleAnim1 = useRef(new Animated.Value(0)).current;
const rippleAnim2 = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (incomingCall) {
blurActiveElement();
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: 1000,
toValue: 1.05,
duration: 1200,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
duration: 1200,
useNativeDriver: true,
}),
])
]),
);
const ripple = Animated.loop(
const ripple1 = Animated.loop(
Animated.sequence([
Animated.timing(rippleAnim, {
Animated.timing(rippleAnim1, {
toValue: 1,
duration: 2000,
duration: 2200,
useNativeDriver: true,
}),
Animated.timing(rippleAnim, {
Animated.timing(rippleAnim1, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
])
]),
);
Animated.parallel([pulse, ripple]).start();
const ripple2 = Animated.loop(
Animated.sequence([
Animated.delay(1100),
Animated.timing(rippleAnim2, {
toValue: 1,
duration: 2200,
useNativeDriver: true,
}),
Animated.timing(rippleAnim2, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
]),
);
Animated.parallel([pulse, ripple1, ripple2]).start();
return () => {
pulse.stop();
ripple.stop();
ripple1.stop();
ripple2.stop();
};
}
}, [incomingCall, pulseAnim, rippleAnim]);
const handleAccept = () => {
acceptCall();
};
const handleReject = () => {
rejectCall();
};
}, [incomingCall, pulseAnim, rippleAnim1, rippleAnim2]);
if (!incomingCall) return null;
const callerName = incomingCall.callerName || '未知用户';
const callerInitial = callerName.charAt(0).toUpperCase();
const isVideoCall = incomingCall.callType === 'video';
return (
<Modal
visible={!!incomingCall}
visible
transparent
animationType="fade"
statusBarTranslucent
>
<View style={styles.overlay}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<View style={styles.root}>
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
{/* Avatar with ripple effect - positioned in upper area */}
{/* Top safe area + label */}
<View style={[styles.topLabel, { paddingTop: insets.top + 16 }]}>
<Text style={styles.topLabelText}>
{isVideoCall ? '视频通话邀请' : '语音通话邀请'}
</Text>
</View>
{/* Avatar section with ripples */}
<View style={styles.avatarSection}>
{/* Ripple rings */}
{[0, 1, 2].map((i) => (
{/* Ripple rings — staggered */}
{[
{ anim: rippleAnim1, scaleRange: [1, 1.6], opacityRange: [0.2, 0] as const },
{ anim: rippleAnim2, scaleRange: [1, 1.4], opacityRange: [0.15, 0] as const },
].map(({ anim, scaleRange, opacityRange }, i) => (
<Animated.View
key={i}
style={[
styles.rippleRing,
styles.ripple,
{
transform: [
{
scale: rippleAnim.interpolate({
scale: anim.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.5 + i * 0.2],
outputRange: scaleRange,
}),
},
],
opacity: rippleAnim.interpolate({
inputRange: [0, 0.4, 1],
outputRange: [0.25, 0.12, 0],
opacity: anim.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [0, ...opacityRange],
}),
},
]}
/>
))}
<Animated.View
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
>
{incomingCall.callerAvatar ? (
<Image
source={{ uri: incomingCall.callerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<View style={styles.avatarBorder}>
{incomingCall.callerAvatar ? (
<Image source={{ uri: incomingCall.callerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarFallback]}>
<Text style={styles.avatarInitial}>{callerInitial}</Text>
</View>
)}
</View>
</Animated.View>
</View>
{/* Caller info */}
<View style={styles.infoSection}>
<Text style={styles.callerName} numberOfLines={1}>
{incomingCall.callerName || '未知用户'}
{callerName}
</Text>
<Text style={styles.callType}>
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
{isVideoCall ? '邀请你进行视频通话' : '邀请你进行语音通话'}
</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* Bottom action buttons */}
<View style={[styles.actions, { paddingBottom: insets.bottom + 32 }]}>
{/* Decline */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleReject}
style={styles.actionTouchable}
onPress={rejectCall}
activeOpacity={0.8}
>
<View style={styles.declineCircle}>
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
<MaterialCommunityIcons
name="phone-hangup"
size={BTN_ICON}
color="#fff"
/>
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
{/* Accept */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleAccept}
style={styles.actionTouchable}
onPress={acceptCall}
activeOpacity={0.8}
>
<View style={styles.acceptCircle}>
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
<MaterialCommunityIcons
name="phone"
size={BTN_ICON}
color="#fff"
/>
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
@@ -171,36 +201,45 @@ const IncomingCallModal: React.FC = () => {
);
};
const AVATAR_SIZE = 100;
const ACTION_CIRCLE_SIZE = 60;
export default IncomingCallModal;
const styles = StyleSheet.create({
overlay: {
root: {
flex: 1,
backgroundColor: '#0D0D0D',
backgroundColor: '#0A0A14',
},
avatarSection: {
position: 'absolute',
top: '18%',
left: 0,
right: 0,
height: AVATAR_SIZE + 60,
justifyContent: 'center',
topLabel: {
alignItems: 'center',
},
rippleRing: {
topLabelText: {
fontSize: 13,
fontWeight: '500',
color: 'rgba(255,255,255,0.35)',
letterSpacing: 0.5,
textTransform: 'uppercase',
},
// ---- Avatar + ripples ----
avatarSection: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
maxHeight: 260,
},
ripple: {
position: 'absolute',
width: AVATAR_SIZE + 16,
height: AVATAR_SIZE + 16,
borderRadius: (AVATAR_SIZE + 16) / 2,
width: AVATAR_SIZE + 32,
height: AVATAR_SIZE + 32,
borderRadius: (AVATAR_SIZE + 32) / 2,
borderWidth: 2,
borderColor: '#4CD964',
},
avatarContainer: {
width: AVATAR_SIZE + 8,
height: AVATAR_SIZE + 8,
borderRadius: (AVATAR_SIZE + 8) / 2,
backgroundColor: '#4CD964',
avatarBorder: {
width: AVATAR_SIZE + 10,
height: AVATAR_SIZE + 10,
borderRadius: (AVATAR_SIZE + 10) / 2,
borderWidth: 2.5,
borderColor: 'rgba(76, 217, 100, 0.4)',
justifyContent: 'center',
alignItems: 'center',
},
@@ -208,72 +247,80 @@ const styles = StyleSheet.create({
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
backgroundColor: '#3A3A5C',
backgroundColor: '#2A2A4E',
},
avatarPlaceholder: {
avatarFallback: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 36,
avatarInitial: {
fontSize: 42,
color: '#fff',
fontWeight: '600',
},
// ---- Info ----
infoSection: {
position: 'absolute',
top: '42%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 40,
marginTop: -20,
marginBottom: 40,
},
callerName: {
fontSize: 24,
fontSize: 28,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
maxWidth: 300,
},
callType: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
fontSize: 15,
fontWeight: '400',
color: 'rgba(255,255,255,0.45)',
marginTop: 8,
},
controls: {
position: 'absolute',
bottom: '12%',
left: 0,
right: 0,
// ---- Actions ----
actions: {
flexDirection: 'row',
justifyContent: 'center',
gap: 60,
gap: 72,
paddingHorizontal: 40,
},
actionButton: {
actionTouchable: {
alignItems: 'center',
width: 80,
},
declineCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
width: BTN_SIZE,
height: BTN_SIZE,
borderRadius: BTN_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#E54D42',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.45,
shadowRadius: 10,
elevation: 8,
},
acceptCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
width: BTN_SIZE,
height: BTN_SIZE,
borderRadius: BTN_SIZE / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#4CD964',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.45,
shadowRadius: 10,
elevation: 8,
},
actionLabel: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
marginTop: 10,
fontSize: 13,
fontWeight: '500',
color: 'rgba(255,255,255,0.5)',
marginTop: 12,
},
});
export default IncomingCallModal;

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

@@ -154,9 +154,9 @@ export class MediaCacheManager {
if (recordKeys.length === 0) return;
const records = await AsyncStorage.multiGet(recordKeys);
for (const [key, value] of records) {
const records = await AsyncStorage.getMany(recordKeys);
for (const [key, value] of Object.entries(records)) {
if (value) {
const entry: CacheEntry = JSON.parse(value);
// 验证文件是否存在
@@ -614,7 +614,7 @@ export class MediaCacheManager {
const keys = await AsyncStorage.getAllKeys();
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
if (recordKeys.length > 0) {
await AsyncStorage.multiRemove(recordKeys);
await AsyncStorage.removeMany(recordKeys);
}
}

View File

@@ -107,9 +107,9 @@ export const DataStorageScreen: React.FC = () => {
const keys = await AsyncStorage.getAllKeys();
if (keys.length > 0) {
const items = await AsyncStorage.multiGet(keys);
const items = await AsyncStorage.getMany(keys);
let totalSize = 0;
for (const [, value] of items) {
for (const value of Object.values(items)) {
if (value) {
totalSize += value.length * 2;
}

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;

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

View File

@@ -3,4 +3,4 @@
*/
export { callStore } from './callStore';
export type { CallType, CallSession, CallStatus, IncomingCallInfo } from './callStore';
export type { CallType, CallSession, CallStatus, IncomingCallInfo, CallParticipant } from './callStore';