diff --git a/app/products/calls/components/call_message/call_message.tsx b/app/products/calls/components/call_message/call_message.tsx index 94ccd7aa2..841366e46 100644 --- a/app/products/calls/components/call_message/call_message.tsx +++ b/app/products/calls/components/call_message/call_message.tsx @@ -3,7 +3,7 @@ import moment from 'moment-timezone'; import React, {useCallback} from 'react'; -import {injectIntl, IntlShape} from 'react-intl'; +import {injectIntl, intlShape, IntlShape} from 'react-intl'; import {View, TouchableOpacity, Text} from 'react-native'; import CompassIcon from '@components/compass_icon'; @@ -19,7 +19,7 @@ import type {UserProfile} from '@mm-redux/types/users'; type CallMessageProps = { actions: { - joinCall: (channelId: string) => void; + joinCall: (channelId: string, intl: typeof intlShape) => void; }; post: Post; user: UserProfile; diff --git a/app/products/calls/components/channel_info/calls_channel_info.tsx b/app/products/calls/components/channel_info/calls_channel_info.tsx index 57e2f36be..736e332e1 100644 --- a/app/products/calls/components/channel_info/calls_channel_info.tsx +++ b/app/products/calls/components/channel_info/calls_channel_info.tsx @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; +import {intlShape} from 'react-intl'; import {Theme} from '@mm-redux/types/theme'; import EnableDisableCalls from '@mmproducts/calls/components/channel_info/enable_disable_calls'; @@ -10,7 +11,7 @@ import {useCallsChannelSettings} from '@mmproducts/calls/hooks'; type Props = { theme: Theme; - joinCall: (channelId: string) => void; + joinCall: (channelId: string, intl: typeof intlShape) => void; } const CallsChannelInfo = ({theme, joinCall}: Props) => { diff --git a/app/products/calls/components/channel_info/start_call.tsx b/app/products/calls/components/channel_info/start_call.tsx index b6fc5f127..abc88a10b 100644 --- a/app/products/calls/components/channel_info/start_call.tsx +++ b/app/products/calls/components/channel_info/start_call.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; -import {injectIntl, IntlShape} from 'react-intl'; +import {injectIntl, intlShape, IntlShape} from 'react-intl'; import {useSelector} from 'react-redux'; import {getChannel, getCurrentChannel} from '@mm-redux/selectors/entities/channels'; @@ -19,7 +19,7 @@ type Props = { testID: string; theme: Theme; intl: typeof IntlShape; - joinCall: (channelId: string) => void; + joinCall: (channelId: string, intl: typeof intlShape) => void; } const StartCall = ({testID, theme, intl, joinCall}: Props) => { diff --git a/app/products/calls/components/join_call/join_call.tsx b/app/products/calls/components/join_call/join_call.tsx index 4c6092c60..c3540512c 100644 --- a/app/products/calls/components/join_call/join_call.tsx +++ b/app/products/calls/components/join_call/join_call.tsx @@ -18,7 +18,7 @@ import type {Call} from '@mmproducts/calls/store/types/calls'; type Props = { actions: { - joinCall: (channelId: string) => any; + joinCall: (channelId: string, intl: typeof IntlShape) => void; }; theme: Theme; call: Call; diff --git a/app/products/calls/components/leave_and_join_alert.tsx b/app/products/calls/components/leave_and_join_alert.tsx index 5c49a92aa..338e30798 100644 --- a/app/products/calls/components/leave_and_join_alert.tsx +++ b/app/products/calls/components/leave_and_join_alert.tsx @@ -4,7 +4,7 @@ import {IntlShape} from 'react-intl'; import {Alert} from 'react-native'; -export default function leaveAndJoinWithAlert(intl: typeof IntlShape, channelId: string, callChannelName: string, currentChannelName: string, confirmToJoin: boolean, joinCall: (channelId: string) => void) { +export default function leaveAndJoinWithAlert(intl: typeof IntlShape, channelId: string, callChannelName: string, currentChannelName: string, confirmToJoin: boolean, joinCall: (channelId: string, intl: typeof IntlShape) => void) { if (confirmToJoin) { Alert.alert( 'Are you sure you want to switch to a different call?', @@ -15,12 +15,12 @@ export default function leaveAndJoinWithAlert(intl: typeof IntlShape, channelId: }, { text: 'Leave & Join', - onPress: () => joinCall(channelId), + onPress: () => joinCall(channelId, intl), style: 'cancel', }, ], ); } else { - joinCall(channelId); + joinCall(channelId, intl); } } diff --git a/app/products/calls/store/actions/calls.test.js b/app/products/calls/store/actions/calls.test.js index cc621b679..afb307e1b 100644 --- a/app/products/calls/store/actions/calls.test.js +++ b/app/products/calls/store/actions/calls.test.js @@ -3,10 +3,12 @@ import assert from 'assert'; +import {IntlProvider} from 'react-intl'; import InCallManager from 'react-native-incall-manager'; import {Client4} from '@client/rest'; import configureStore from '@test/test_store'; +import * as PermissionUtils from '@utils/permission'; import CallsTypes from '../action_types/calls'; @@ -73,6 +75,9 @@ describe('Actions.Calls', () => { let store; const {newClient} = require('@mmproducts/calls/connection'); InCallManager.setSpeakerphoneOn = jest.fn(); + const intlProvider = new IntlProvider({locale: 'en'}, {}); + const {intl} = intlProvider.getChildContext(); + jest.spyOn(PermissionUtils, 'hasMicrophonePermission').mockReturnValue(true); beforeEach(async () => { newClient.mockClear(); @@ -86,7 +91,7 @@ describe('Actions.Calls', () => { it('joinCall', async () => { await store.dispatch(addFakeCall('channel-id')); - const response = await store.dispatch(CallsActions.joinCall('channel-id')); + const response = await store.dispatch(CallsActions.joinCall('channel-id', intl)); const result = store.getState().entities.calls.joined; assert.equal('channel-id', result); assert.equal(response.data, 'channel-id'); @@ -99,7 +104,7 @@ describe('Actions.Calls', () => { await store.dispatch(addFakeCall('channel-id')); expect(CallsActions.ws).toBe(null); - await store.dispatch(CallsActions.joinCall('channel-id')); + await store.dispatch(CallsActions.joinCall('channel-id', intl)); let result = store.getState().entities.calls.joined; assert.equal('channel-id', result); @@ -115,7 +120,7 @@ describe('Actions.Calls', () => { it('muteMyself', async () => { await store.dispatch(addFakeCall('channel-id')); - await store.dispatch(CallsActions.joinCall('channel-id')); + await store.dispatch(CallsActions.joinCall('channel-id', intl)); await store.dispatch(CallsActions.muteMyself()); expect(CallsActions.ws.mute).toBeCalled(); await store.dispatch(CallsActions.leaveCall()); diff --git a/app/products/calls/store/actions/calls.ts b/app/products/calls/store/actions/calls.ts index f5603f1ca..abfe2e610 100644 --- a/app/products/calls/store/actions/calls.ts +++ b/app/products/calls/store/actions/calls.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {intlShape} from 'react-intl'; import InCallManager from 'react-native-incall-manager'; import {Client4} from '@client/rest'; @@ -13,6 +14,7 @@ import {newClient} from '@mmproducts/calls/connection'; import CallsTypes from '@mmproducts/calls/store/action_types/calls'; import {getConfig} from '@mmproducts/calls/store/selectors/calls'; import {Call, CallParticipant, DefaultServerConfig} from '@mmproducts/calls/store/types/calls'; +import {hasMicrophonePermission} from '@utils/permission'; export let ws: any = null; @@ -131,8 +133,13 @@ export function disableChannelCalls(channelId: string): ActionFunc { }; } -export function joinCall(channelId: string): ActionFunc { +export function joinCall(channelId: string, intl: typeof intlShape): ActionFunc { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const hasPermission = await hasMicrophonePermission(intl); + if (!hasPermission) { + return {error: 'no permissions to microphone, unable to start call'}; + } + const setScreenShareURL = (url: string) => { dispatch({ type: CallsTypes.SET_SCREENSHARE_URL, diff --git a/app/screens/channel_info/channel_info.js b/app/screens/channel_info/channel_info.js index bce315e91..2e80bd09f 100644 --- a/app/screens/channel_info/channel_info.js +++ b/app/screens/channel_info/channel_info.js @@ -89,8 +89,8 @@ export default class ChannelInfo extends PureComponent { dismissModal(); }; - joinCallHandler = (channelId) => { - this.props.actions.joinCall(channelId); + joinCallHandler = (channelId, intl) => { + this.props.actions.joinCall(channelId, intl); this.close(); }; diff --git a/app/utils/permission.ts b/app/utils/permission.ts index 10b091500..e4ff9d8e0 100644 --- a/app/utils/permission.ts +++ b/app/utils/permission.ts @@ -65,6 +65,21 @@ const getStoragePermissionDeniedMessage = (intl: typeof intlShape) => { }; }; +const getMicrophonePermissionDeniedMessage = (intl: typeof intlShape) => { + const {formatMessage} = intl; + const applicationName = DeviceInfo.getApplicationName(); + return { + title: formatMessage({ + id: 'mobile.microphone_permission_denied_title', + defaultMessage: '{applicationName} would like to access your microphone', + }, {applicationName}), + text: formatMessage({ + id: 'mobile.microphone_permission_denied_description', + defaultMessage: 'To participate in this call, open Settings to grant Mattermost access to your microphone.', + }), + }; +}; + export const hasCameraPermission = async (intl: typeof intlShape) => { const {formatMessage} = intl; const targetSource = Platform.OS === 'ios' ? Permissions.PERMISSIONS.IOS.CAMERA : Permissions.PERMISSIONS.ANDROID.CAMERA; @@ -108,6 +123,49 @@ export const hasCameraPermission = async (intl: typeof intlShape) => { return true; }; +export const hasMicrophonePermission = async (intl: typeof intlShape) => { + const {formatMessage} = intl; + const targetSource = Platform.OS === 'ios' ? Permissions.PERMISSIONS.IOS.MICROPHONE : Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO; + const hasPermission = await Permissions.check(targetSource); + + switch (hasPermission) { + case Permissions.RESULTS.DENIED: + case Permissions.RESULTS.UNAVAILABLE: { + const permissionRequest = await Permissions.request(targetSource); + + return permissionRequest === Permissions.RESULTS.GRANTED; + } + case Permissions.RESULTS.BLOCKED: { + const grantOption = { + text: formatMessage({ + id: 'mobile.permission_denied_retry', + defaultMessage: 'Settings', + }), + onPress: () => Permissions.openSettings(), + }; + + const {title, text} = getMicrophonePermissionDeniedMessage(intl); + + Alert.alert( + title, + text, + [ + grantOption, + { + text: formatMessage({ + id: 'mobile.permission_denied_dismiss', + defaultMessage: 'Don\'t Allow', + }), + }, + ], + ); + return false; + } + } + + return true; +}; + export const hasPhotoPermission = async (intl: typeof intlShape) => { const {formatMessage} = intl; const targetSource = Platform.OS === 'ios' ? Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY : Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index f5b9bf196..1297824c3 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -415,6 +415,8 @@ "mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}", "mobile.message_length.message_split_left": "Message exceeds the character limit", "mobile.message_length.title": "Message Length", + "mobile.microphone_permission_denied_description": "To participate in this call, open Settings to grant Mattermost access to your microphone.", + "mobile.microphone_permission_denied_title": "{applicationName} would like to access your microphone", "mobile.more_dms.add_more": "You can add {remaining, number} more users", "mobile.more_dms.cannot_add_more": "You cannot add more users", "mobile.more_dms.one_more": "You can add 1 more user",