Skip to content

Commit eefbb9b

Browse files
authored
fix(voip): ship blockers for PushKit, licensing, outbound calls, push tokens (#7167)
1 parent abefd00 commit eefbb9b

8 files changed

Lines changed: 95 additions & 30 deletions

File tree

app/containers/NewMediaCall/CreateCall.test.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { fireEvent, render } from '@testing-library/react-native';
2+
import { act, fireEvent, render } from '@testing-library/react-native';
33
import { Provider } from 'react-redux';
44

55
import { CreateCall } from './CreateCall';
@@ -99,7 +99,7 @@ describe('CreateCall', () => {
9999
expect(button.props.accessibilityState?.disabled).toBe(false);
100100
});
101101

102-
it('should call startCall with user type when user peer is selected and pressed', () => {
102+
it('should call startCall with user type when user peer is selected and pressed', async () => {
103103
setStoreState(userPeer);
104104
const { getByTestId } = render(
105105
<Wrapper>
@@ -108,12 +108,13 @@ describe('CreateCall', () => {
108108
);
109109

110110
fireEvent.press(getByTestId('new-media-call-button'));
111+
await act(() => Promise.resolve());
111112
expect(mockStartCall).toHaveBeenCalledTimes(1);
112113
expect(mockStartCall).toHaveBeenCalledWith('user-1', 'user');
113114
expect(mockHideActionSheet).toHaveBeenCalledTimes(1);
114115
});
115116

116-
it('should call startCall when SIP peer is selected and pressed', () => {
117+
it('should call startCall when SIP peer is selected and pressed', async () => {
117118
setStoreState(sipPeer);
118119
const { getByTestId } = render(
119120
<Wrapper>
@@ -122,6 +123,7 @@ describe('CreateCall', () => {
122123
);
123124

124125
fireEvent.press(getByTestId('new-media-call-button'));
126+
await act(() => Promise.resolve());
125127
expect(mockStartCall).toHaveBeenCalledTimes(1);
126128
expect(mockStartCall).toHaveBeenCalledWith('+5511999999999', 'sip');
127129
expect(mockHideActionSheet).toHaveBeenCalledTimes(1);

app/containers/NewMediaCall/CreateCall.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,26 @@ import { CustomIcon } from '../CustomIcon';
77
import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore';
88
import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance';
99
import { hideActionSheetRef } from '../ActionSheet';
10+
import { showErrorAlert } from '../../lib/methods/helpers/info';
1011
import sharedStyles from '../../views/Styles';
1112

1213
export const CreateCall = () => {
1314
const { colors } = useTheme();
1415

1516
const selectedPeer = usePeerAutocompleteStore(state => state.selectedPeer);
1617

17-
const handleCall = () => {
18+
const handleCall = async () => {
1819
if (!selectedPeer) {
1920
return;
2021
}
2122

22-
mediaSessionInstance.startCall(selectedPeer.value, selectedPeer.type);
23-
hideActionSheetRef();
23+
try {
24+
await mediaSessionInstance.startCall(selectedPeer.value, selectedPeer.type);
25+
hideActionSheetRef();
26+
} catch (e) {
27+
const message = e instanceof Error && e.message ? e.message : I18n.t('VoIP_Call_Issue');
28+
showErrorAlert(message, I18n.t('Oops'));
29+
}
2430
};
2531

2632
const isCallDisabled = !selectedPeer;

app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ describe('VoIP call lifecycle (integration)', () => {
413413

414414
// ── Outgoing calls (button press path) ───────────────────────────────────
415415

416-
it('user peer: press Call → startCall fires newCall → navigates to CallView', () => {
416+
it('user peer: press Call → startCall fires newCall → navigates to CallView', async () => {
417417
setSelectedPeer({ type: 'user', value: 'user-1', label: 'Alice', username: 'alice' });
418418
const session = createdSessions[createdSessions.length - 1];
419419

@@ -429,6 +429,7 @@ describe('VoIP call lifecycle (integration)', () => {
429429
// → mock fires 'newCall' → MediaSessionInstance handler
430430
// → useCallStore.setCall + Navigation.navigate('CallView')
431431
fireEvent.press(getByTestId('new-media-call-button'));
432+
await act(() => Promise.resolve());
432433

433434
expect(session.startCall).toHaveBeenCalledWith('user', 'user-1');
434435
expect(Navigation.navigate).toHaveBeenCalledWith('CallView');
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { clearEnterpriseModules, setEnterpriseModules } from '../../actions/enterpriseModules';
2+
import { initStore } from '../store/auxStore';
3+
import { mockedStore } from '../../reducers/mockedStore';
4+
import { isVoipModuleAvailable } from './enterpriseModules';
5+
6+
describe('isVoipModuleAvailable', () => {
7+
beforeAll(() => {
8+
initStore(mockedStore);
9+
});
10+
11+
beforeEach(() => {
12+
mockedStore.dispatch(clearEnterpriseModules());
13+
});
14+
15+
it('returns false when enterpriseModules is empty', () => {
16+
expect(isVoipModuleAvailable()).toBe(false);
17+
});
18+
19+
it('returns false when teams-voip is absent', () => {
20+
mockedStore.dispatch(setEnterpriseModules(['omnichannel-mobile-enterprise']));
21+
expect(isVoipModuleAvailable()).toBe(false);
22+
});
23+
24+
it('returns true when teams-voip is present', () => {
25+
mockedStore.dispatch(setEnterpriseModules(['teams-voip']));
26+
expect(isVoipModuleAvailable()).toBe(true);
27+
});
28+
});

app/lib/methods/enterpriseModules.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,6 @@ export function isOmnichannelModuleAvailable() {
6363
}
6464

6565
export function isVoipModuleAvailable() {
66-
// const { enterpriseModules } = reduxStore.getState();
67-
return true; // enterpriseModules.includes('teams-voip');
66+
const { enterpriseModules } = reduxStore.getState();
67+
return enterpriseModules.includes('teams-voip');
6868
}

app/lib/services/restApi.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,11 +1095,6 @@ export const registerPushToken = async (): Promise<void> => {
10951095
return;
10961096
}
10971097

1098-
// TODO: voice permission check and retry to avoid race condition
1099-
if (isIOS && (!token || !voipToken)) {
1100-
return;
1101-
}
1102-
11031098
let data: TRegisterPushTokenData = {
11041099
id: await getUniqueId(),
11051100
value: '',

app/lib/services/voip/MediaSessionInstance.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,16 @@ class MediaSessionInstance {
172172
useCallStore.getState().setRoomId(room.rid ?? null);
173173
const otherUserId = getUidDirectMessage(room);
174174
if (otherUserId) {
175-
this.startCall(otherUserId, 'user');
175+
this.startCall(otherUserId, 'user').catch(error => {
176+
console.error('[VoIP] Error starting call from room:', error);
177+
});
176178
}
177179
};
178180

179-
public startCall = (userId: string, actor: CallActorType) => {
181+
public startCall = async (userId: string, actor: CallActorType): Promise<void> => {
180182
requestPhoneStatePermission();
181183
console.log('[VoIP] Starting call:', userId);
182-
this.instance?.startCall(actor, userId);
184+
await this.instance?.startCall(actor, userId);
183185
};
184186

185187
public endCall = (callId: string) => {

ios/Libraries/AppDelegate+Voip.swift

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@ import PushKit
22

33
fileprivate let voipAppDelegateLogTag = "RocketChat.AppDelegate+Voip"
44

5+
/// Shared CallKit reporting for VoIP PushKit payloads (`handle` and `localizedCallerName` may differ; often both are the caller display name).
6+
fileprivate func reportVoipIncomingCallToCallKit(
7+
callUUID: String,
8+
handle: String,
9+
localizedCallerName: String,
10+
payload: [AnyHashable: Any],
11+
onReportComplete: @escaping () -> Void
12+
) {
13+
RNCallKeep.reportNewIncomingCall(
14+
callUUID,
15+
handle: handle,
16+
handleType: "generic",
17+
hasVideo: false,
18+
localizedCallerName: localizedCallerName,
19+
supportsHolding: true,
20+
supportsDTMF: true,
21+
supportsGrouping: false,
22+
supportsUngrouping: false,
23+
fromPushKit: true,
24+
payload: payload,
25+
withCompletionHandler: onReportComplete
26+
)
27+
}
28+
529
// MARK: - PKPushRegistryDelegate
630

731
extension AppDelegate: PKPushRegistryDelegate {
@@ -22,11 +46,26 @@ extension AppDelegate: PKPushRegistryDelegate {
2246
public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
2347
let payloadDict = payload.dictionaryPayload
2448

49+
/// PushKit requires reporting to CallKit before `completion()`. For expired or unparseable payloads,
50+
/// report a short-lived incoming call and end it so the system is not left without a CallKit update.
51+
let reportPlaceholderCallAndEnd: (_ callUUID: String, _ displayName: String) -> Void = { callUUID, displayName in
52+
reportVoipIncomingCallToCallKit(
53+
callUUID: callUUID,
54+
handle: displayName,
55+
localizedCallerName: displayName,
56+
payload: payloadDict,
57+
onReportComplete: {
58+
RNCallKeep.endCall(withUUID: callUUID, reason: 1)
59+
completion()
60+
}
61+
)
62+
}
63+
2564
guard let voipPayload = VoipPayload.fromDictionary(payloadDict) else {
2665
#if DEBUG
2766
print("[\(voipAppDelegateLogTag)] Failed to parse incoming VoIP payload: \(payloadDict)")
2867
#endif
29-
completion()
68+
reportPlaceholderCallAndEnd(UUID().uuidString, "Rocket.Chat")
3069
return
3170
}
3271

@@ -36,26 +75,18 @@ extension AppDelegate: PKPushRegistryDelegate {
3675
#if DEBUG
3776
print("[\(voipAppDelegateLogTag)] Skipping expired or invalid VoIP payload for callId: \(callId): \(voipPayload)")
3877
#endif
39-
completion()
78+
reportPlaceholderCallAndEnd(callId, caller)
4079
return
4180
}
4281

4382
VoipService.prepareIncomingCall(voipPayload, storeEventsForJs: true)
4483

45-
RNCallKeep.reportNewIncomingCall(
46-
callId,
84+
reportVoipIncomingCallToCallKit(
85+
callUUID: callId,
4786
handle: caller,
48-
handleType: "generic",
49-
hasVideo: false,
5087
localizedCallerName: caller,
51-
supportsHolding: true,
52-
supportsDTMF: true,
53-
supportsGrouping: false,
54-
supportsUngrouping: false,
55-
fromPushKit: true,
5688
payload: payloadDict,
57-
withCompletionHandler: {}
89+
onReportComplete: { completion() }
5890
)
59-
completion()
6091
}
6192
}

0 commit comments

Comments
 (0)