diff --git a/app/containers/NewMediaCall/CreateCall.tsx b/app/containers/NewMediaCall/CreateCall.tsx index 3cbfa1e191..c322566d97 100644 --- a/app/containers/NewMediaCall/CreateCall.tsx +++ b/app/containers/NewMediaCall/CreateCall.tsx @@ -7,6 +7,7 @@ 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 = () => { @@ -14,13 +15,18 @@ export const CreateCall = () => { 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; diff --git a/app/lib/methods/enterpriseModules.test.ts b/app/lib/methods/enterpriseModules.test.ts new file mode 100644 index 0000000000..105cab8042 --- /dev/null +++ b/app/lib/methods/enterpriseModules.test.ts @@ -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); + }); +}); diff --git a/app/lib/methods/enterpriseModules.ts b/app/lib/methods/enterpriseModules.ts index ac55884753..2c252df779 100644 --- a/app/lib/methods/enterpriseModules.ts +++ b/app/lib/methods/enterpriseModules.ts @@ -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'); } diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index 0e2aca6ef0..fef49488f8 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1095,11 +1095,6 @@ export const registerPushToken = async (): Promise => { return; } - // TODO: voice permission check and retry to avoid race condition - if (isIOS && (!token || !voipToken)) { - return; - } - let data: TRegisterPushTokenData = { id: await getUniqueId(), value: '', diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 6a7183e74f..89e8284c2e 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -174,10 +174,10 @@ class MediaSessionInstance { } }; - public startCall = (userId: string, actor: CallActorType) => { + public startCall = async (userId: string, actor: CallActorType): Promise => { requestPhoneStatePermission(); console.log('[VoIP] Starting call:', userId); - this.instance?.startCall(actor, userId); + await Promise.resolve(this.instance?.startCall(actor, userId)); }; public endCall = (callId: string) => { diff --git a/ios/Libraries/AppDelegate+Voip.swift b/ios/Libraries/AppDelegate+Voip.swift index a5d36d60af..c2d1ecbe1f 100644 --- a/ios/Libraries/AppDelegate+Voip.swift +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -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 { @@ -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 } @@ -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() }