Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions app/containers/NewMediaCall/CreateCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,26 @@ import { CustomIcon } from '../CustomIcon';
import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore';
import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance';
import { hideActionSheetRef } from '../ActionSheet';
import { showErrorAlert } from '../../lib/methods/helpers/info';
import sharedStyles from '../../views/Styles';

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

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

const handleCall = () => {
const handleCall = async () => {
if (!selectedPeer) {
return;
}

mediaSessionInstance.startCall(selectedPeer.value, selectedPeer.type);
hideActionSheetRef();
try {
await mediaSessionInstance.startCall(selectedPeer.value, selectedPeer.type);
hideActionSheetRef();
} catch (e) {
const message = e instanceof Error && e.message ? e.message : I18n.t('VoIP_Call_Issue');
showErrorAlert(message, I18n.t('Oops'));
}
};

const isCallDisabled = !selectedPeer;
Expand Down
24 changes: 24 additions & 0 deletions app/lib/methods/enterpriseModules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { clearEnterpriseModules, setEnterpriseModules } from '../../actions/enterpriseModules';
import { initStore } from '../store/auxStore';
import { mockedStore } from '../../reducers/mockedStore';
import { isVoipModuleAvailable } from './enterpriseModules';

describe('isVoipModuleAvailable', () => {
beforeAll(() => {
initStore(mockedStore);
});

beforeEach(() => {
mockedStore.dispatch(clearEnterpriseModules());
});

it('returns false when teams-voip is absent', () => {
mockedStore.dispatch(setEnterpriseModules(['omnichannel-mobile-enterprise']));
expect(isVoipModuleAvailable()).toBe(false);
});

it('returns true when teams-voip is present', () => {
mockedStore.dispatch(setEnterpriseModules(['teams-voip']));
expect(isVoipModuleAvailable()).toBe(true);
});
});
4 changes: 2 additions & 2 deletions app/lib/methods/enterpriseModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@ export function isOmnichannelModuleAvailable() {
}

export function isVoipModuleAvailable() {
// const { enterpriseModules } = reduxStore.getState();
return true; // enterpriseModules.includes('teams-voip');
const { enterpriseModules } = reduxStore.getState();
return enterpriseModules.includes('teams-voip');
}
5 changes: 0 additions & 5 deletions app/lib/services/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,11 +1095,6 @@ export const registerPushToken = async (): Promise<void> => {
return;
}

// TODO: voice permission check and retry to avoid race condition
if (isIOS && (!token || !voipToken)) {
return;
}

let data: TRegisterPushTokenData = {
id: await getUniqueId(),
value: '',
Expand Down
4 changes: 2 additions & 2 deletions app/lib/services/voip/MediaSessionInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,10 @@ class MediaSessionInstance {
}
};

public startCall = (userId: string, actor: CallActorType) => {
public startCall = async (userId: string, actor: CallActorType): Promise<void> => {
requestPhoneStatePermission();
console.log('[VoIP] Starting call:', userId);
this.instance?.startCall(actor, userId);
await Promise.resolve(this.instance?.startCall(actor, userId));
};

public endCall = (callId: string) => {
Expand Down
56 changes: 44 additions & 12 deletions ios/Libraries/AppDelegate+Voip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@ import PushKit

fileprivate let voipAppDelegateLogTag = "RocketChat.AppDelegate+Voip"

/// Shared CallKit reporting for VoIP PushKit payloads (`handle` and `localizedCallerName` may differ; often both are the caller display name).
fileprivate func reportVoipIncomingCallToCallKit(
callUUID: String,
handle: String,
localizedCallerName: String,
payload: [AnyHashable: Any],
onReportComplete: @escaping () -> Void
) {
RNCallKeep.reportNewIncomingCall(
callUUID,
handle: handle,
handleType: "generic",
hasVideo: false,
localizedCallerName: localizedCallerName,
supportsHolding: true,
supportsDTMF: true,
supportsGrouping: false,
supportsUngrouping: false,
fromPushKit: true,
payload: payload,
withCompletionHandler: onReportComplete
)
}

// MARK: - PKPushRegistryDelegate

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

/// PushKit requires reporting to CallKit before `completion()`. For expired or unparseable payloads,
/// report a short-lived incoming call and end it so the system is not left without a CallKit update.
let reportPlaceholderCallAndEnd: (_ callUUID: String, _ displayName: String) -> Void = { callUUID, displayName in
reportVoipIncomingCallToCallKit(
callUUID: callUUID,
handle: displayName,
localizedCallerName: displayName,
payload: payloadDict,
onReportComplete: {
RNCallKeep.endCall(withUUID: callUUID, reason: 1)
completion()
}
)
}

guard let voipPayload = VoipPayload.fromDictionary(payloadDict) else {
#if DEBUG
print("[\(voipAppDelegateLogTag)] Failed to parse incoming VoIP payload: \(payloadDict)")
#endif
completion()
reportPlaceholderCallAndEnd(UUID().uuidString, "Rocket.Chat")
return
}

Expand All @@ -36,25 +75,18 @@ extension AppDelegate: PKPushRegistryDelegate {
#if DEBUG
print("[\(voipAppDelegateLogTag)] Skipping expired or invalid VoIP payload for callId: \(callId): \(voipPayload)")
#endif
completion()
reportPlaceholderCallAndEnd(callId, caller)
return
}

VoipService.prepareIncomingCall(voipPayload, storeEventsForJs: true)

RNCallKeep.reportNewIncomingCall(
callId,
reportVoipIncomingCallToCallKit(
callUUID: callId,
handle: caller,
handleType: "generic",
hasVideo: false,
localizedCallerName: caller,
supportsHolding: true,
supportsDTMF: true,
supportsGrouping: false,
supportsUngrouping: false,
fromPushKit: true,
payload: payloadDict,
withCompletionHandler: {}
onReportComplete: {}
)
completion()
Comment on lines +84 to 91
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

On iOS 13+ PushKit didReceiveIncomingPushWith, must completion() be invoked only after CallKit reportNewIncomingCall's completion handler fires?

💡 Result:

Yes. For iOS 13.0+ VoIP PushKit callbacks, you should invoke the PushKit completion only after you have finished reporting the call to CallKit—i.e., inside the completion block of CXProvider.reportNewIncomingCall(with:update:completion:) (after it fires), not before. Apple’s PushKit delegate documentation explicitly notes that when calling CallKit from pushRegistry(_:didReceiveIncomingPushWith:for:completion:), “If you fail to report a call to CallKit, the system will terminate your app,” and the PushKit “Responding to VoIP Notifications” example calls PushKit completion from inside the CallKit reportNewIncomingCall completion block. This implies the required ordering: report to CallKit first, then tell PushKit you’re done after that report completes.

Citations:


🏁 Script executed:

wc -l ios/Libraries/AppDelegate+Voip.swift

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 117


🏁 Script executed:

cat -n ios/Libraries/AppDelegate+Voip.swift | head -100

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 4107


Happy-path calls completion() before CallKit reporting completes — violates Apple's PushKit requirement.

The error path correctly defers completion() into the onReportComplete callback (line 59), but the happy path passes an empty onReportComplete: {} and then calls completion() synchronously at line 91. Per Apple's PushKit documentation (iOS 13+), completion() must be invoked only after CallKit's reportNewIncomingCall callback fires. Calling it before can cause the system to terminate the app for not fulfilling the PushKit entitlement contract.

Route completion() through the helper's onReportComplete parameter to match the error path:

Proposed fix
     reportVoipIncomingCallToCallKit(
       callUUID: callId,
       handle: caller,
       localizedCallerName: caller,
       payload: payloadDict,
-      onReportComplete: {}
+      onReportComplete: { completion() }
     )
-    completion()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
reportVoipIncomingCallToCallKit(
callUUID: callId,
handle: caller,
handleType: "generic",
hasVideo: false,
localizedCallerName: caller,
supportsHolding: true,
supportsDTMF: true,
supportsGrouping: false,
supportsUngrouping: false,
fromPushKit: true,
payload: payloadDict,
withCompletionHandler: {}
onReportComplete: {}
)
completion()
reportVoipIncomingCallToCallKit(
callUUID: callId,
handle: caller,
localizedCallerName: caller,
payload: payloadDict,
onReportComplete: { completion() }
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Libraries/AppDelegate`+Voip.swift around lines 84 - 91, The happy-path
currently calls completion() immediately after invoking
reportVoipIncomingCallToCallKit, which violates PushKit rules; instead pass the
completion closure into reportVoipIncomingCallToCallKit's onReportComplete
parameter (i.e. replace onReportComplete: {} and the subsequent completion()
call with onReportComplete: { completion() }) so the completion handler runs
only after reportNewIncomingCall's callback completes.

}
Expand Down
Loading