diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index fe087ba85..424b0cac2 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -43,6 +43,7 @@ import {newConnection} from '../connection/connection'; import type { ApiResp, + AudioDevice, Call, CallParticipant, CallsConnection, @@ -352,6 +353,10 @@ export const setSpeakerphoneOn = (speakerphoneOn: boolean) => { setSpeakerPhone(speakerphoneOn); }; +export const setPreferredAudioRoute = async (audio: AudioDevice) => { + return InCallManager.chooseAudioRoute(audio); +}; + export const canEndCall = async (serverUrl: string, channelId: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { diff --git a/app/products/calls/actions/permissions.ts b/app/products/calls/actions/permissions.ts index a4ac3709d..4174230f2 100644 --- a/app/products/calls/actions/permissions.ts +++ b/app/products/calls/actions/permissions.ts @@ -4,24 +4,45 @@ import {Platform} from 'react-native'; import Permissions from 'react-native-permissions'; -export const hasMicrophonePermission = async () => { - const targetSource = Platform.select({ - ios: Permissions.PERMISSIONS.IOS.MICROPHONE, - default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO, +export const hasBluetoothPermission = async () => { + const bluetooth = Platform.select({ + ios: Permissions.PERMISSIONS.IOS.BLUETOOTH_PERIPHERAL, + default: Permissions.PERMISSIONS.ANDROID.BLUETOOTH_CONNECT, }); - const hasPermission = await Permissions.check(targetSource); - switch (hasPermission) { + const hasBluetooth = await Permissions.check(bluetooth); + + switch (hasBluetooth) { case Permissions.RESULTS.DENIED: case Permissions.RESULTS.UNAVAILABLE: { - const permissionRequest = await Permissions.request(targetSource); - + const permissionRequest = await Permissions.request(bluetooth); return permissionRequest === Permissions.RESULTS.GRANTED; } case Permissions.RESULTS.BLOCKED: return false; + default: + return true; + } +}; + +export const hasMicrophonePermission = async () => { + const microphone = Platform.select({ + ios: Permissions.PERMISSIONS.IOS.MICROPHONE, + default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO, + }); + + const hasMicrophone = await Permissions.check(microphone); + + switch (hasMicrophone) { + case Permissions.RESULTS.DENIED: + case Permissions.RESULTS.UNAVAILABLE: { + const permissionRequest = await Permissions.request(microphone); + return permissionRequest === Permissions.RESULTS.GRANTED; + } + case Permissions.RESULTS.BLOCKED: + return false; + default: + return true; } - - return true; }; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index aac5a91c3..f3e89d595 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -5,6 +5,7 @@ import {Alert} from 'react-native'; import {hasMicrophonePermission, joinCall, unmuteMyself} from '@calls/actions'; import {leaveCallPopCallScreen} from '@calls/actions/calls'; +import {hasBluetoothPermission} from '@calls/actions/permissions'; import { getCallsConfig, getCallsState, @@ -192,6 +193,8 @@ const doJoinCall = async ( recordingAlertLock = false; recordingWillBePostedLock = true; // only unlock if/when the user stops a recording. + + await hasBluetoothPermission(); const hasPermission = await hasMicrophonePermission(); setMicPermissionsGranted(hasPermission); diff --git a/app/products/calls/components/audio_device_button.ios.tsx b/app/products/calls/components/audio_device_button.ios.tsx new file mode 100644 index 000000000..40f6936bf --- /dev/null +++ b/app/products/calls/components/audio_device_button.ios.tsx @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Pressable, type StyleProp, Text, type TextStyle, type ViewStyle} from 'react-native'; + +import {setSpeakerphoneOn} from '@calls/actions'; +import CompassIcon from '@components/compass_icon'; + +import type {CurrentCall} from '@calls/types/calls'; + +type Props = { + pressableStyle: StyleProp; + iconStyle: StyleProp; + buttonTextStyle: StyleProp; + currentCall: CurrentCall; +} + +export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, currentCall}: Props) => { + const intl = useIntl(); + const speakerLabel = intl.formatMessage({id: 'mobile.calls_speaker', defaultMessage: 'SpeakerPhone'}); + + const toggleSpeakerPhone = useCallback(() => { + setSpeakerphoneOn(!currentCall?.speakerphoneOn); + }, [currentCall?.speakerphoneOn]); + + return ( + + + {speakerLabel} + + ); +}; diff --git a/app/products/calls/components/audio_device_button.tsx b/app/products/calls/components/audio_device_button.tsx new file mode 100644 index 000000000..1348125de --- /dev/null +++ b/app/products/calls/components/audio_device_button.tsx @@ -0,0 +1,124 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Pressable, type StyleProp, Text, type TextStyle, View, type ViewStyle} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {setPreferredAudioRoute} from '@calls/actions/calls'; +import {AudioDevice, type CurrentCall} from '@calls/types/calls'; +import CompassIcon from '@components/compass_icon'; +import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; +import {Device} from '@constants'; +import {useTheme} from '@context/theme'; +import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {typography} from '@utils/typography'; + +type Props = { + pressableStyle: StyleProp; + iconStyle: StyleProp; + buttonTextStyle: StyleProp; + currentCall: CurrentCall; +} + +const style = { + bold: typography('Body', 200, 'SemiBold'), +}; + +export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, currentCall}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const {bottom} = useSafeAreaInsets(); + const isTablet = Device.IS_TABLET; // not `useIsTablet` because even if we're in splitView, we're still using a tablet. + const color = theme.awayIndicator; + const audioDeviceInfo = currentCall.audioDeviceInfo; + const phoneLabel = intl.formatMessage({id: 'mobile.calls_phone', defaultMessage: 'Phone'}); + const tabletLabel = intl.formatMessage({id: 'mobile.calls_tablet', defaultMessage: 'Tablet'}); + const speakerLabel = intl.formatMessage({id: 'mobile.calls_speaker', defaultMessage: 'SpeakerPhone'}); + const bluetoothLabel = intl.formatMessage({id: 'mobile.calls_bluetooth', defaultMessage: 'Bluetooth'}); + + const deviceSelector = useCallback(async () => { + const currentDevice = audioDeviceInfo.selectedAudioDevice; + const available = audioDeviceInfo.availableAudioDeviceList; + const selectDevice = (device: AudioDevice) => { + setPreferredAudioRoute(device); + dismissBottomSheet(); + }; + + const renderContent = () => { + return ( + + {available.includes(AudioDevice.Earpiece) && isTablet && + selectDevice(AudioDevice.Earpiece)} + text={tabletLabel} + textStyles={currentDevice === AudioDevice.Earpiece ? {...style.bold, color} : {}} + /> + } + {available.includes(AudioDevice.Earpiece) && !isTablet && + selectDevice(AudioDevice.Earpiece)} + text={phoneLabel} + textStyles={currentDevice === AudioDevice.Earpiece ? {...style.bold, color} : {}} + /> + } + {available.includes(AudioDevice.Speakerphone) && + selectDevice(AudioDevice.Speakerphone)} + text={speakerLabel} + textStyles={currentDevice === AudioDevice.Speakerphone ? {...style.bold, color} : {}} + /> + } + {available.includes(AudioDevice.Bluetooth) && + selectDevice(AudioDevice.Bluetooth)} + text={bluetoothLabel} + textStyles={currentDevice === AudioDevice.Bluetooth ? {...style.bold, color} : {}} + /> + } + + ); + }; + + await bottomSheet({ + closeButtonId: 'close-other-actions', + renderContent, + snapPoints: [1, bottomSheetSnapPoint(audioDeviceInfo.availableAudioDeviceList.length + 1, ITEM_HEIGHT, bottom)], + title: intl.formatMessage({id: 'mobile.calls_audio_device', defaultMessage: 'Select audio device'}), + theme, + }); + }, [setPreferredAudioRoute, audioDeviceInfo, color]); + + let icon = 'volume-high'; + let label = speakerLabel; + switch (audioDeviceInfo.selectedAudioDevice) { + case AudioDevice.Earpiece: + icon = isTablet ? 'tablet' : 'cellphone'; + label = isTablet ? tabletLabel : phoneLabel; + break; + case AudioDevice.Bluetooth: + icon = 'bluetooth'; + label = bluetoothLabel; + break; + } + + return ( + + + {label} + + ); +}; diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts index f5943a206..de04f1264 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/connection.ts @@ -3,25 +3,21 @@ import {RTCPeer} from '@mattermost/calls/lib'; import {deflate} from 'pako'; -import {DeviceEventEmitter, type EmitterSubscription} from 'react-native'; +import {DeviceEventEmitter, type EmitterSubscription, Platform} from 'react-native'; import InCallManager from 'react-native-incall-manager'; -import { - MediaStream, - MediaStreamTrack, - mediaDevices, - RTCPeerConnection, -} from 'react-native-webrtc'; +import {mediaDevices, MediaStream, MediaStreamTrack, RTCPeerConnection} from 'react-native-webrtc'; -import {setSpeakerPhone} from '@calls/state'; +import {setPreferredAudioRoute, setSpeakerphoneOn} from '@calls/actions/calls'; +import {setAudioDeviceInfo} from '@calls/state'; +import {AudioDevice, type AudioDeviceInfo, type AudioDeviceInfoRaw, type CallsConnection} from '@calls/types/calls'; import {getICEServersConfigs} from '@calls/utils'; import {WebsocketEvents} from '@constants'; import {getServerCredentials} from '@init/credentials'; import NetworkManager from '@managers/network_manager'; -import {logError, logDebug, logWarning, logInfo} from '@utils/log'; +import {logDebug, logError, logInfo, logWarning} from '@utils/log'; import {WebSocketClient, wsReconnectionTimeoutErr} from './websocket_client'; -import type {CallsConnection} from '@calls/types/calls'; import type {EmojiData} from '@mattermost/calls/lib/types'; const peerConnectTimeout = 5000; @@ -40,6 +36,7 @@ export async function newConnection( let voiceTrack: MediaStreamTrack | null = null; let isClosed = false; let onCallEnd: EmitterSubscription | null = null; + let audioDeviceChanged: EmitterSubscription | null = null; const streams: MediaStream[] = []; const initializeVoiceTrack = async () => { @@ -97,6 +94,7 @@ export async function newConnection( peer?.destroy(); peer = null; InCallManager.stop(); + audioDeviceChanged?.remove(); if (closeCb) { closeCb(); @@ -201,8 +199,36 @@ export async function newConnection( } } - InCallManager.start({media: 'video'}); - setSpeakerPhone(true); + InCallManager.start(); + InCallManager.stopProximitySensor(); + + let btInitialized = false; + let speakerInitialized = false; + + audioDeviceChanged = DeviceEventEmitter.addListener('onAudioDeviceChanged', (data: AudioDeviceInfoRaw) => { + const info: AudioDeviceInfo = { + availableAudioDeviceList: JSON.parse(data.availableAudioDeviceList), + selectedAudioDevice: data.selectedAudioDevice, + }; + setAudioDeviceInfo(info); + + // Auto switch to bluetooth the first time we connect to bluetooth, but not after. + if (!btInitialized) { + if (info.availableAudioDeviceList.includes(AudioDevice.Bluetooth)) { + setPreferredAudioRoute(AudioDevice.Bluetooth); + btInitialized = true; + } else if (!speakerInitialized) { + // If we don't have bluetooth available, default to speakerphone on. + setPreferredAudioRoute(AudioDevice.Speakerphone); + speakerInitialized = true; + } + } + }); + + // We default to speakerphone (Android is handled above in the onAudioDeviceChanged handler above). + if (Platform.OS === 'ios') { + setSpeakerphoneOn(true); + } peer = new RTCPeer({ iceServers: iceConfigs || [], diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index ddb035b22..bcb02e1c6 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -19,9 +19,10 @@ import {Navigation} from 'react-native-navigation'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {RTCView} from 'react-native-webrtc'; -import {leaveCall, muteMyself, setSpeakerphoneOn, unmuteMyself} from '@calls/actions'; +import {leaveCall, muteMyself, unmuteMyself} from '@calls/actions'; import {startCallRecording, stopCallRecording} from '@calls/actions/calls'; import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} from '@calls/alerts'; +import {AudioDeviceButton} from '@calls/components/audio_device_button'; import CallAvatar from '@calls/components/call_avatar'; import CallDuration from '@calls/components/call_duration'; import CallsBadge, {CallsBadgeType} from '@calls/components/calls_badge'; @@ -305,8 +306,14 @@ const CallScreen = ({ const callThreadOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Call Thread'}); const recordOptionTitle = intl.formatMessage({id: 'mobile.calls_record', defaultMessage: 'Record'}); - const stopRecordingOptionTitle = intl.formatMessage({id: 'mobile.calls_stop_recording', defaultMessage: 'Stop Recording'}); - const openChannelOptionTitle = intl.formatMessage({id: 'mobile.calls_open_channel', defaultMessage: 'Open Channel'}); + const stopRecordingOptionTitle = intl.formatMessage({ + id: 'mobile.calls_stop_recording', + defaultMessage: 'Stop Recording', + }); + const openChannelOptionTitle = intl.formatMessage({ + id: 'mobile.calls_open_channel', + defaultMessage: 'Open Channel', + }); useEffect(() => { mergeNavigationOptions('Call', { @@ -349,10 +356,6 @@ const CallScreen = ({ setShowReactions((prev) => !prev); }, [setShowReactions]); - const toggleSpeakerPhone = useCallback(() => { - setSpeakerphoneOn(!currentCall?.speakerphoneOn); - }, [currentCall?.speakerphoneOn]); - const toggleControlsInLandscape = useCallback(() => { setShowControlsInLandscape(!showControlsInLandscape); }, [showControlsInLandscape]); @@ -658,27 +661,17 @@ const CallScreen = ({ style={style.buttonText} /> - - - - + { assert.deepEqual(result.current[1], null); }); + it('setAudioDeviceInfo', () => { + const initialCallsState = { + ...DefaultCallsState, + myUserId: 'myUserId', + calls: {'channel-1': call1, 'channel-2': call2}, + }; + const newCall1 = { + ...call1, + participants: { + ...call1.participants, + myUserId: {id: 'myUserId', muted: true, raisedHand: 0}, + }, + }; + const expectedCallsState = { + ...initialCallsState, + calls: { + ...initialCallsState.calls, + 'channel-1': newCall1, + }, + }; + + const defaultAudioDeviceInfo = { + availableAudioDeviceList: [], + selectedAudioDevice: AudioDevice.None, + }; + const newAudioDeviceInfo = { + availableAudioDeviceList: [AudioDevice.Speakerphone, AudioDevice.Earpiece], + selectedAudioDevice: AudioDevice.Speakerphone, + }; + + // setup + const {result} = renderHook(() => { + return [useCallsState('server1'), useCurrentCall()]; + }); + act(() => setCallsState('server1', initialCallsState)); + assert.deepEqual(result.current[0], initialCallsState); + assert.deepEqual(result.current[1], null); + + // test + act(() => newCurrentCall('server1', 'channel-1', 'myUserId')); + act(() => userJoinedCall('server1', 'channel-1', 'myUserId')); + assert.deepEqual((result.current[1] as CurrentCall | null)?.audioDeviceInfo, defaultAudioDeviceInfo); + act(() => setAudioDeviceInfo(newAudioDeviceInfo)); + assert.deepEqual((result.current[1] as CurrentCall | null)?.audioDeviceInfo, newAudioDeviceInfo); + assert.deepEqual((result.current[1] as CurrentCall | null)?.speakerphoneOn, false); + assert.deepEqual(result.current[0], expectedCallsState); + act(() => { + myselfLeftCall(); + }); + assert.deepEqual(result.current[0], expectedCallsState); + assert.deepEqual(result.current[1], null); + }); + it('MicPermissions', () => { const initialGlobalState = DefaultGlobalCallsState; const initialCallsState: CallsState = { diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index e60bdce78..e958fc00f 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -16,6 +16,7 @@ import { setGlobalCallsState, } from '@calls/state'; import { + type AudioDeviceInfo, type Call, type CallsConfigState, type ChannelsWithCalls, @@ -419,6 +420,13 @@ export const setSpeakerPhone = (speakerphoneOn: boolean) => { } }; +export const setAudioDeviceInfo = (info: AudioDeviceInfo) => { + const call = getCurrentCall(); + if (call) { + setCurrentCall({...call, audioDeviceInfo: info}); + } +}; + export const setConfig = (serverUrl: string, config: Partial) => { const callsConfig = getCallsConfig(serverUrl); setCallsConfig(serverUrl, {...callsConfig, ...config}); diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 974da36f6..4a621b3f4 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -45,12 +45,21 @@ export const DefaultCall: Call = { hostId: '', }; +export enum AudioDevice { + Earpiece = 'EARPIECE', + Speakerphone = 'SPEAKER_PHONE', + Bluetooth = 'BLUETOOTH', + WiredHeadset = 'WIRED_HEADSET', + None = 'NONE', +} + export type CurrentCall = Call & { connected: boolean; serverUrl: string; myUserId: string; screenShareURL: string; speakerphoneOn: boolean; + audioDeviceInfo: AudioDeviceInfo; voiceOn: Dictionary; micPermissionsErrorDismissed: boolean; reactionStream: ReactionStreamEmoji[]; @@ -64,6 +73,7 @@ export const DefaultCurrentCall: CurrentCall = { myUserId: '', screenShareURL: '', speakerphoneOn: false, + audioDeviceInfo: {availableAudioDeviceList: [], selectedAudioDevice: AudioDevice.None}, voiceOn: {}, micPermissionsErrorDismissed: false, reactionStream: [], @@ -147,3 +157,13 @@ export type ReactionStreamEmoji = { count: number; literal?: string; }; + +export type AudioDeviceInfoRaw = { + availableAudioDeviceList: string; + selectedAudioDevice: AudioDevice; +}; + +export type AudioDeviceInfo = { + availableAudioDeviceList: AudioDevice[]; + selectedAudioDevice: AudioDevice; +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index ca413fd62..07aab0ba3 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -416,6 +416,8 @@ "mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.", "mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos", "mobile.announcement_banner.title": "Announcement", + "mobile.calls_audio_device": "Select audio device", + "mobile.calls_bluetooth": "Bluetooth", "mobile.calls_call_ended": "Call ended", "mobile.calls_call_screen": "Call", "mobile.calls_call_thread": "Call Thread", @@ -463,6 +465,7 @@ "mobile.calls_participant_limit_title_GA": "This call is at capacity", "mobile.calls_participant_rec": "The host has started recording this meeting. By staying in the meeting you give consent to being recorded.", "mobile.calls_participant_rec_title": "Recording is in progress", + "mobile.calls_phone": "Phone", "mobile.calls_raise_hand": "Raise hand", "mobile.calls_react": "React", "mobile.calls_rec": "rec", diff --git a/package-lock.json b/package-lock.json index b96662024..74bd6be48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@formatjs/intl-relativetimeformat": "11.2.1", "@gorhom/bottom-sheet": "4.4.5", "@mattermost/calls": "github:mattermost/calls-common#v0.12.0", - "@mattermost/compass-icons": "0.1.35", + "@mattermost/compass-icons": "0.1.36", "@mattermost/react-native-emm": "1.3.5", "@mattermost/react-native-network-client": "1.3.3", "@mattermost/react-native-paste-input": "0.6.2", @@ -3426,9 +3426,9 @@ } }, "node_modules/@mattermost/compass-icons": { - "version": "0.1.35", - "resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.35.tgz", - "integrity": "sha512-qCZjvU6Vsl7cg/0urrq8ItK5CXnt3DqjFq/w9mBpWuem/YhMWLMVBoSuASAx9S2HV7Vr/iMdsMGWH/4qXkXCBg==" + "version": "0.1.36", + "resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.36.tgz", + "integrity": "sha512-0v816WW2WmBxdXJxXUs6g1EP4xPF99tBxl7Uo8ocF0+E5T7y4YN7+bjz/pAme5eWuD6Yexc+otzkn+mWjqFU/A==" }, "node_modules/@mattermost/react-native-emm": { "version": "1.3.5", @@ -24273,9 +24273,9 @@ } }, "@mattermost/compass-icons": { - "version": "0.1.35", - "resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.35.tgz", - "integrity": "sha512-qCZjvU6Vsl7cg/0urrq8ItK5CXnt3DqjFq/w9mBpWuem/YhMWLMVBoSuASAx9S2HV7Vr/iMdsMGWH/4qXkXCBg==" + "version": "0.1.36", + "resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.36.tgz", + "integrity": "sha512-0v816WW2WmBxdXJxXUs6g1EP4xPF99tBxl7Uo8ocF0+E5T7y4YN7+bjz/pAme5eWuD6Yexc+otzkn+mWjqFU/A==" }, "@mattermost/react-native-emm": { "version": "1.3.5", diff --git a/package.json b/package.json index 4d43bd5b0..5b04515c2 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@formatjs/intl-relativetimeformat": "11.2.1", "@gorhom/bottom-sheet": "4.4.5", "@mattermost/calls": "github:mattermost/calls-common#v0.12.0", - "@mattermost/compass-icons": "0.1.35", + "@mattermost/compass-icons": "0.1.36", "@mattermost/react-native-emm": "1.3.5", "@mattermost/react-native-network-client": "1.3.3", "@mattermost/react-native-paste-input": "0.6.2", diff --git a/types/modules/react-native-incall-manager.d.ts b/types/modules/react-native-incall-manager.d.ts index f58bde157..5c5960bf8 100644 --- a/types/modules/react-native-incall-manager.d.ts +++ b/types/modules/react-native-incall-manager.d.ts @@ -60,7 +60,7 @@ declare module 'react-native-incall-manager' { getAudioUri(audioType: string, fileType: string): any; - chooseAudioRoute(route: any): any; + chooseAudioRoute(route: any): Promise; requestAudioFocus(): void;