Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class VoipCallService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
isRunning = false
Log.d(TAG, "Stopping VoipCallService")
stopSelf(startId)
return START_NOT_STICKY
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
VoipNotification.stopDDPClient()
}

override fun stopVoipCallService() {
try {
VoipCallService.stopService(reactApplicationContext)
Log.d(TAG, "stopVoipCallService: service stopped")
} catch (e: Exception) {
Log.e(TAG, "stopVoipCallService: failed to stop service", e)
}
}

/**
* Required for NativeEventEmitter in TurboModules.
* Called when JS starts listening to events.
Expand Down
8 changes: 8 additions & 0 deletions app/lib/native/NativeVoip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export interface Spec extends TurboModule {
*/
stopNativeDDPClient(): void;

/**
* Stops the VoIP foreground call service.
* iOS: No-op.
* Android: Sends ACTION_STOP to VoipCallService, releasing the mic-type foreground hold.
*/
stopVoipCallService(): void;

/**
* Required for NativeEventEmitter in TurboModules.
* Called when JS starts listening to events.
Expand All @@ -58,6 +65,7 @@ const NativeVoipModule =
clearInitialEvents: () => undefined,
getLastVoipToken: () => '',
stopNativeDDPClient: () => undefined,
stopVoipCallService: () => undefined,
addListener: () => undefined,
removeListeners: () => undefined
} as Spec);
Expand Down
15 changes: 15 additions & 0 deletions app/lib/navigation/appNavigation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from 'react';
import { CommonActions, type NavigationContainerRef, StackActions } from '@react-navigation/native';

import { emitter } from '../methods/helpers';

// TODO: we need change this any to the correctly types from our stacks
const navigationRef = React.createRef<NavigationContainerRef<any>>();
const routeNameRef: React.MutableRefObject<NavigationContainerRef<any> | null> = React.createRef();
Expand Down Expand Up @@ -74,6 +76,19 @@ function setParams(params: any) {
navigationRef.current?.setParams(params);
}

export function waitForNavigationReady(): Promise<void> {
if (navigationRef.current) {
return Promise.resolve();
}
return new Promise(resolve => {
const listener = () => {
emitter.off('navigationReady', listener);
resolve();
};
emitter.on('navigationReady', listener);
});
}

export default {
navigationRef,
routeNameRef,
Expand Down
4 changes: 4 additions & 0 deletions app/lib/services/voip/MediaCallEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ export const getInitialMediaCallEvents = async (adapters: MediaCallEventsAdapter
useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId);

if (initialEvents.host && isVoipIncomingHostCurrentWorkspace(initialEvents.host, adapters.getActiveServerUrl)) {
if (!isIOS) {
mediaCallLogger.log(`${TAG} Same workspace as VoIP host; continuing appInit for cold-start handoff`);
return false;
}
mediaSessionInstance.applyRestStateSignals().catch(error => {
mediaCallLogger.error(`${TAG} applyRestStateSignals (initial) failed:`, error);
});
Expand Down
13 changes: 11 additions & 2 deletions app/lib/services/voip/MediaSessionInstance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,16 @@ jest.mock('react-native-callkeep', () => ({
}));

jest.mock('react-native-device-info', () => ({
default: {
getUniqueId: jest.fn(() => 'test-device-id'),
getUniqueIdSync: jest.fn(() => 'test-device-id'),
hasNotch: jest.fn(() => false),
getReadableVersion: jest.fn(() => '1.0.0')
},
getUniqueId: jest.fn(() => 'test-device-id'),
getUniqueIdSync: jest.fn(() => 'test-device-id')
getUniqueIdSync: jest.fn(() => 'test-device-id'),
hasNotch: jest.fn(() => false),
getReadableVersion: jest.fn(() => '1.0.0')
}));

jest.mock('../../native/NativeVoip', () => ({
Expand All @@ -95,7 +103,8 @@ jest.mock('../../native/NativeVoip', () => ({

jest.mock('../../navigation/appNavigation', () => ({
__esModule: true,
default: { navigate: jest.fn() }
default: { navigate: jest.fn() },
waitForNavigationReady: jest.fn().mockResolvedValue(undefined)
}));

const mockRequestPhoneStatePermission = jest.fn();
Expand Down
10 changes: 6 additions & 4 deletions app/lib/services/voip/MediaSessionInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import { registerGlobals } from 'react-native-webrtc';
import { getUniqueIdSync } from 'react-native-device-info';

import { mediaSessionStore } from './MediaSessionStore';
import { terminateNativeCall } from './terminateNativeCall';
import { useCallStore } from './useCallStore';
import { store } from '../../store/auxStore';
import sdk from '../sdk';
import { mediaCallsStateSignals } from '../restApi';
import Navigation from '../../navigation/appNavigation';
import Navigation, { waitForNavigationReady } from '../../navigation/appNavigation';
import { parseStringToIceServers } from './parseStringToIceServers';
import type { IceServer } from '../../../definitions/Voip';
import type { IDDPMessage } from '../../../definitions/IDDPMessage';
Expand Down Expand Up @@ -128,7 +129,7 @@ class MediaSessionInstance {
}

call.emitter.on('ended', () => {
RNCallKeep.endCall(call.callId);
terminateNativeCall(call.callId);
});
}
});
Expand All @@ -146,12 +147,13 @@ class MediaSessionInstance {
await mainCall.accept();
RNCallKeep.setCurrentCallActive(callId);
useCallStore.getState().setCall(mainCall);
await waitForNavigationReady();
Navigation.navigate('CallView');
this.resolveRoomIdFromContact(mainCall.remoteParticipants[0]?.contact).catch(error => {
console.error('[VoIP] Error resolving room id from contact (answerCall):', error);
});
} else {
RNCallKeep.endCall(callId);
terminateNativeCall(callId);
const st = useCallStore.getState();
if (st.nativeAcceptedCallId === callId) {
st.resetNativeCallId();
Expand Down Expand Up @@ -185,7 +187,7 @@ class MediaSessionInstance {
mainCall.hangup();
}
}
RNCallKeep.endCall(callId);
terminateNativeCall(callId);
RNCallKeep.setCurrentCallActive('');
RNCallKeep.setAvailable(true);
useCallStore.getState().resetNativeCallId();
Expand Down
15 changes: 15 additions & 0 deletions app/lib/services/voip/terminateNativeCall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Platform } from 'react-native';
import RNCallKeep from 'react-native-callkeep';

import NativeVoipModule from '../../native/NativeVoip';

export function terminateNativeCall(callId: string): void {
RNCallKeep.endCall(callId);
if (Platform.OS === 'android') {
try {
NativeVoipModule.stopVoipCallService();
} catch {
// bridge unavailable pre-boot
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
16 changes: 8 additions & 8 deletions app/lib/services/voip/useCallStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,9 @@ describe('useCallStore native accepted + stale timer', () => {
expect(s.callId).toBeNull();
});

it('after 15s unbound, clears nativeAcceptedCallId when id still matches scheduled token', () => {
it('after 60s unbound, clears nativeAcceptedCallId when id still matches scheduled token', () => {
useCallStore.getState().setNativeAcceptedCallId('stale');
jest.advanceTimersByTime(15_000);
jest.advanceTimersByTime(60_000);
const s = useCallStore.getState();
expect(s.nativeAcceptedCallId).toBeNull();
expect(s.callId).toBeNull();
Expand All @@ -244,26 +244,26 @@ describe('useCallStore native accepted + stale timer', () => {
it('setCall clears native id and cancels stale timer so advance does not clear bound call context', () => {
useCallStore.getState().setNativeAcceptedCallId('x');
useCallStore.getState().setCall(createMockCall('x').call);
jest.advanceTimersByTime(15_000);
jest.advanceTimersByTime(60_000);
expect(useCallStore.getState().call).not.toBeNull();
expect(useCallStore.getState().nativeAcceptedCallId).toBeNull();
});

it('reset() preserves id and restarts 15s window from last reset', () => {
it('reset() preserves id and restarts 60s window from last reset', () => {
useCallStore.getState().setNativeAcceptedCallId('keep');
jest.advanceTimersByTime(14_000);
jest.advanceTimersByTime(59_000);
useCallStore.getState().reset();
jest.advanceTimersByTime(14_000);
jest.advanceTimersByTime(59_000);
expect(useCallStore.getState().nativeAcceptedCallId).toBe('keep');
jest.advanceTimersByTime(1_000);
expect(useCallStore.getState().nativeAcceptedCallId).toBeNull();
});

it('replacing native id restarts timer so old deadline does not clear new id', () => {
useCallStore.getState().setNativeAcceptedCallId('a');
jest.advanceTimersByTime(14_000);
jest.advanceTimersByTime(59_000);
useCallStore.getState().setNativeAcceptedCallId('b');
jest.advanceTimersByTime(14_000);
jest.advanceTimersByTime(59_000);
expect(useCallStore.getState().nativeAcceptedCallId).toBe('b');
jest.advanceTimersByTime(1_000);
expect(useCallStore.getState().nativeAcceptedCallId).toBeNull();
Expand Down
5 changes: 3 additions & 2 deletions app/lib/services/voip/useCallStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import type { CallState, CallContact, IClientMediaCall } from '@rocket.chat/medi
import RNCallKeep from 'react-native-callkeep';
import InCallManager from 'react-native-incall-manager';

import { terminateNativeCall } from './terminateNativeCall';
import Navigation from '../../navigation/appNavigation';
import { hideActionSheetRef } from '../../../containers/ActionSheet';
import { useIsScreenReaderEnabled } from '../../hooks/useIsScreenReaderEnabled';

const STALE_NATIVE_MS = 15_000;
const STALE_NATIVE_MS = 60_000;

let callListenersCleanup: (() => void) | null = null;
let staleNativeTimer: ReturnType<typeof setTimeout> | null = null;
Expand Down Expand Up @@ -282,7 +283,7 @@ export const useCallStore = create<CallStore>((set, get) => ({
}

if (callUuid) {
RNCallKeep.endCall(callUuid);
terminateNativeCall(callUuid);
}

get().resetNativeCallId();
Expand Down
22 changes: 4 additions & 18 deletions app/sagas/deepLinking.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import database from '../lib/database';
import { getServerById } from '../lib/database/services/Server';
import { canOpenRoom } from '../lib/methods/canOpenRoom';
import { getServerInfo } from '../lib/methods/getServerInfo';
import { emitter, getUidDirectMessage, normalizeDeepLinkingServerHost } from '../lib/methods/helpers';
import { getUidDirectMessage, normalizeDeepLinkingServerHost } from '../lib/methods/helpers';
import EventEmitter from '../lib/methods/helpers/events';
import { goRoom, navigateToRoom } from '../lib/methods/helpers/goRoom';
import { localAuthenticate } from '../lib/methods/helpers/localAuthentication';
Expand All @@ -26,7 +26,7 @@ import { videoConfJoin } from '../lib/methods/videoConf';
import { loginOAuthOrSso } from '../lib/services/connect';
import { notifyUser } from '../lib/services/restApi';
import sdk from '../lib/services/sdk';
import Navigation from '../lib/navigation/appNavigation';
import Navigation, { waitForNavigationReady } from '../lib/navigation/appNavigation';
import { resetVoipState } from '../lib/services/voip/resetVoipState';

const roomTypes = {
Expand All @@ -47,20 +47,6 @@ const handleInviteLink = function* handleInviteLink({ params, requireLogin = fal
}
};

const waitForNavigation = () => {
if (Navigation.navigationRef.current) {
return Promise.resolve();
}
return new Promise(resolve => {
const listener = () => {
emitter.off('navigationReady', listener);
resolve();
};

emitter.on('navigationReady', listener);
});
};

const navigate = function* navigate({ params }) {
if (params.path || params.rid) {
let type;
Expand All @@ -82,7 +68,7 @@ const navigate = function* navigate({ params }) {

const isMasterDetail = yield select(state => state.app.isMasterDetail);
const jumpToMessageId = params.messageId;
yield waitForNavigation();
yield waitForNavigationReady();
yield goRoom({ item, isMasterDetail, jumpToMessageId, jumpToThreadId });
}
} else {
Expand All @@ -104,7 +90,7 @@ const handleVoipAcceptFailed = function* handleVoipAcceptFailed(params) {
RNCallKeep.endCall(callId);
}

yield call(waitForNavigation);
yield call(waitForNavigationReady);

const navigateParams = {
...params,
Expand Down
Loading