Skip to content

Commit db9ea02

Browse files
authored
feat(voip): migrate iOS accept/reject from DDP to REST (#7124)
1 parent bb47935 commit db9ea02

File tree

13 files changed

+537
-402
lines changed

13 files changed

+537
-402
lines changed

app/lib/methods/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './getAvatarUrl';
88
export * from './info';
99
export * from './isReadOnly';
1010
export * from './media';
11+
export * from './normalizeDeepLinkingServerHost';
1112
export * from './room';
1213
export * from './server';
1314
export * from './isSsl';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { normalizeDeepLinkingServerHost } from './normalizeDeepLinkingServerHost';
2+
3+
describe('normalizeDeepLinkingServerHost', () => {
4+
it('returns empty string for empty input', () => {
5+
expect(normalizeDeepLinkingServerHost('')).toBe('');
6+
});
7+
8+
it('adds https for host without scheme', () => {
9+
expect(normalizeDeepLinkingServerHost('open.rocket.chat')).toBe('https://open.rocket.chat');
10+
});
11+
12+
it('uses http for localhost', () => {
13+
expect(normalizeDeepLinkingServerHost('localhost')).toBe('http://localhost');
14+
expect(normalizeDeepLinkingServerHost('localhost:3000')).toBe('http://localhost:3000');
15+
});
16+
17+
it('upgrades http to https for non-localhost', () => {
18+
expect(normalizeDeepLinkingServerHost('http://example.com')).toBe('https://example.com');
19+
});
20+
21+
it('strips trailing slash', () => {
22+
expect(normalizeDeepLinkingServerHost('https://example.com/')).toBe('https://example.com');
23+
});
24+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Normalize a Rocket.Chat server base URL for deep linking and VoIP host comparison.
3+
* Matches the historical behavior in `app/sagas/deepLinking.js` (`handleOpen` host handling).
4+
*/
5+
export function normalizeDeepLinkingServerHost(rawHost: string): string {
6+
let host = rawHost;
7+
if (!host) {
8+
return '';
9+
}
10+
if (!/^(http|https)/.test(host)) {
11+
if (/^localhost(:\d+)?/.test(host)) {
12+
host = `http://${host}`;
13+
} else {
14+
host = `https://${host}`;
15+
}
16+
} else {
17+
host = host.replace('http://', 'https://');
18+
}
19+
if (host.slice(-1) === '/') {
20+
host = host.slice(0, host.length - 1);
21+
}
22+
return host;
23+
}

app/lib/services/voip/MediaCallEvents.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ jest.mock('../../methods/helpers', () => ({
1818
isIOS: false
1919
}));
2020

21+
const mockServerSelector = jest.fn(() => 'https://workspace-a.example.com');
2122
jest.mock('../../store', () => ({
2223
__esModule: true,
2324
default: {
24-
dispatch: (...args: unknown[]) => mockDispatch(...args)
25+
dispatch: (...args: unknown[]) => mockDispatch(...args),
26+
getState: () => ({ server: { server: mockServerSelector() } })
2527
}
2628
}));
2729

@@ -51,7 +53,8 @@ jest.mock('react-native-callkeep', () => ({
5153

5254
jest.mock('./MediaSessionInstance', () => ({
5355
mediaSessionInstance: {
54-
endCall: jest.fn()
56+
endCall: jest.fn(),
57+
applyRestStateSignals: jest.fn(() => Promise.resolve())
5558
}
5659
}));
5760

@@ -127,6 +130,21 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => {
127130
});
128131
});
129132

133+
it('skips deepLinkingOpen and replays REST state signals when host matches active workspace', () => {
134+
const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance');
135+
mockServerSelector.mockReturnValueOnce('https://workspace-a.example.com');
136+
const payload = buildIncomingPayload({
137+
callId: 'same-ws-call',
138+
host: 'https://workspace-a.example.com'
139+
});
140+
141+
DeviceEventEmitter.emit('VoipAcceptSucceeded', payload);
142+
143+
expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('same-ws-call');
144+
expect(mediaSessionInstance.applyRestStateSignals).toHaveBeenCalledTimes(1);
145+
expect(mockDispatch).not.toHaveBeenCalled();
146+
});
147+
130148
it('does not dispatch or set native id when type is not incoming_call', () => {
131149
DeviceEventEmitter.emit(
132150
'VoipAcceptSucceeded',

app/lib/services/voip/MediaCallEvents.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import RNCallKeep from 'react-native-callkeep';
22
import { DeviceEventEmitter, NativeEventEmitter } from 'react-native';
33

4-
import { isIOS } from '../../methods/helpers';
4+
import { isIOS, normalizeDeepLinkingServerHost } from '../../methods/helpers';
55
import store from '../../store';
66
import { deepLinkingOpen } from '../../../actions/deepLinking';
77
import { useCallStore } from './useCallStore';
@@ -17,6 +17,15 @@ const TAG = `[MediaCallEvents][${platform}]`;
1717
const EVENT_VOIP_ACCEPT_FAILED = 'VoipAcceptFailed';
1818
const EVENT_VOIP_ACCEPT_SUCCEEDED = 'VoipAcceptSucceeded';
1919

20+
/** True when normalized incoming host matches the active Redux workspace (no server switch needed). */
21+
function isVoipIncomingHostCurrentWorkspace(incomingHost: string): boolean {
22+
const active = store.getState().server.server;
23+
if (!active || !incomingHost) {
24+
return false;
25+
}
26+
return normalizeDeepLinkingServerHost(incomingHost) === normalizeDeepLinkingServerHost(active);
27+
}
28+
2029
/** Dedupe native emit + stash replay for the same failed accept. */
2130
let lastHandledVoipAcceptFailureCallId: string | null = null;
2231
/** Idempotent warm delivery of native accept success. */
@@ -56,6 +65,12 @@ function handleVoipAcceptSucceededFromNative(data: VoipPayload) {
5665
console.log(`${TAG} VoipAcceptSucceeded:`, data);
5766
NativeVoipModule.clearInitialEvents();
5867
useCallStore.getState().setNativeAcceptedCallId(data.callId);
68+
if (data.host && isVoipIncomingHostCurrentWorkspace(data.host)) {
69+
mediaSessionInstance.applyRestStateSignals().catch(error => {
70+
console.error(`${TAG} applyRestStateSignals failed:`, error);
71+
});
72+
return;
73+
}
5974
store.dispatch(
6075
deepLinkingOpen({
6176
callId: data.callId,
@@ -109,8 +124,8 @@ export const setupMediaCallEvents = (): (() => void) => {
109124

110125
// Note: there is intentionally no 'answerCall' listener here.
111126
// VoipService.swift handles accept natively: handleObservedCallChanged detects
112-
// hasConnected = true and calls handleNativeAccept(), which sends the DDP accept
113-
// signal before JS runs. JS receives VoipAcceptSucceeded after success.
127+
// hasConnected = true and calls handleNativeAccept(), which sends the REST accept
128+
// (POST /api/v1/media-calls.answer) before JS runs. JS receives VoipAcceptSucceeded after success.
114129
}
115130

116131
/** Tracks OS-driven hold (competing call) so we only auto-resume that path, not manual hold. */
@@ -223,6 +238,14 @@ export const getInitialMediaCallEvents = async (): Promise<boolean> => {
223238
if (wasAnswered) {
224239
useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId);
225240

241+
if (initialEvents.host && isVoipIncomingHostCurrentWorkspace(initialEvents.host)) {
242+
mediaSessionInstance.applyRestStateSignals().catch(error => {
243+
console.error(`${TAG} applyRestStateSignals (initial) failed:`, error);
244+
});
245+
console.log(`${TAG} Same workspace as VoIP host; skipped deepLinkingOpen`);
246+
return true;
247+
}
248+
226249
store.dispatch(
227250
deepLinkingOpen({
228251
callId: initialEvents.callId,

0 commit comments

Comments
 (0)