diff --git a/.gitignore b/.gitignore index 9b208b47c..a7a100c2d 100644 --- a/.gitignore +++ b/.gitignore @@ -117,4 +117,7 @@ launch.json .metro-health-check* libraries/**/**/build -libraries/**/**/.build \ No newline at end of file +libraries/**/**/.build + +# Android sounds +android/app/src/main/res/raw/* \ No newline at end of file diff --git a/app/components/user_list/__snapshots__/index.test.tsx.snap b/app/components/user_list/__snapshots__/index.test.tsx.snap index 6d4bbe329..c99066ed8 100644 --- a/app/components/user_list/__snapshots__/index.test.tsx.snap +++ b/app/components/user_list/__snapshots__/index.test.tsx.snap @@ -269,6 +269,10 @@ exports[`components/channel_list_row should show results and tutorial 1`] = ` "locale": "", "nickname": "", "notify_props": { + "calls_desktop_sound": "true", + "calls_mobile_notification_sound": "", + "calls_mobile_sound": "", + "calls_notification_sound": "Calm", "channel": "true", "comments": "never", "desktop": "mention", @@ -584,6 +588,10 @@ exports[`components/channel_list_row should show results no tutorial 1`] = ` "locale": "", "nickname": "", "notify_props": { + "calls_desktop_sound": "true", + "calls_mobile_notification_sound": "", + "calls_mobile_sound": "", + "calls_notification_sound": "Calm", "channel": "true", "comments": "never", "desktop": "mention", @@ -899,6 +907,10 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`] "locale": "", "nickname": "", "notify_props": { + "calls_desktop_sound": "true", + "calls_mobile_notification_sound": "", + "calls_mobile_sound": "", + "calls_notification_sound": "Calm", "channel": "true", "comments": "never", "desktop": "mention", @@ -933,6 +945,10 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`] "locale": "", "nickname": "", "notify_props": { + "calls_desktop_sound": "true", + "calls_mobile_notification_sound": "", + "calls_mobile_sound": "", + "calls_notification_sound": "Calm", "channel": "true", "comments": "never", "desktop": "mention", diff --git a/app/components/user_list/index.test.tsx b/app/components/user_list/index.test.tsx index 8bb5bde50..2c3e9c435 100644 --- a/app/components/user_list/index.test.tsx +++ b/app/components/user_list/index.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {Image} from 'react-native'; +import {Ringtone} from '@constants/calls'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; @@ -51,6 +52,10 @@ describe('components/channel_list_row', () => { highlight_keys: '', push: 'mention', push_status: 'away', + calls_desktop_sound: 'true', + calls_mobile_sound: '', + calls_notification_sound: Ringtone.Calm, + calls_mobile_notification_sound: '', }, }; @@ -80,6 +85,10 @@ describe('components/channel_list_row', () => { highlight_keys: '', push: 'mention', push_status: 'away', + calls_desktop_sound: 'true', + calls_mobile_sound: '', + calls_notification_sound: Ringtone.Calm, + calls_mobile_notification_sound: '', }, }; diff --git a/app/constants/calls.ts b/app/constants/calls.ts index 43ecb2676..b83e4832e 100644 --- a/app/constants/calls.ts +++ b/app/constants/calls.ts @@ -32,6 +32,19 @@ const REACTION_TIMEOUT = 10000; const REACTION_LIMIT = 20; const CALL_QUALITY_RESET_MS = toMilliseconds({minutes: 1}); const CAPTION_TIMEOUT = 5000; +const RING_LENGTH = 30000; + +export enum Ringtone { + Calm = 'Calm', + Dynamic = 'Dynamic', + Urgent = 'Urgent', + Cheerful = 'Cheerful', +} + +const RINGTONE_DEFAULT = Ringtone.Calm; + +// 30 seconds of vibration (there is no loop setting) +const RINGTONE_VIBRATE_PATTERN = [1000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 1000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 1000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 1000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 1000, 500, 1000, 500, 1000]; export enum MessageBarType { Microphone, @@ -57,4 +70,7 @@ export default { JOB_TYPE_RECORDING, JOB_TYPE_TRANSCRIBING, JOB_TYPE_CAPTIONING, + RING_LENGTH, + RINGTONE_DEFAULT, + RINGTONE_VIBRATE_PATTERN, }; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 9d53d9425..a65396577 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -64,6 +64,7 @@ export const SETTINGS_NOTIFICATION_AUTO_RESPONDER = 'SettingsNotificationAutoRes export const SETTINGS_NOTIFICATION_EMAIL = 'SettingsNotificationEmail'; export const SETTINGS_NOTIFICATION_MENTION = 'SettingsNotificationMention'; export const SETTINGS_NOTIFICATION_PUSH = 'SettingsNotificationPush'; +export const SETTINGS_NOTIFICATION_CALL = 'SettingsNotificationCall'; export const SHARE_FEEDBACK = 'ShareFeedback'; export const SNACK_BAR = 'SnackBar'; export const SSO = 'SSO'; @@ -139,6 +140,7 @@ export default { SETTINGS_NOTIFICATION_EMAIL, SETTINGS_NOTIFICATION_MENTION, SETTINGS_NOTIFICATION_PUSH, + SETTINGS_NOTIFICATION_CALL, SHARE_FEEDBACK, SNACK_BAR, SSO, diff --git a/app/database/operator/server_data_operator/handlers/user.test.ts b/app/database/operator/server_data_operator/handlers/user.test.ts index 2fd508b15..e2ccdea69 100644 --- a/app/database/operator/server_data_operator/handlers/user.test.ts +++ b/app/database/operator/server_data_operator/handlers/user.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Ringtone} from '@constants/calls'; import DatabaseManager from '@database/manager'; import {buildPreferenceKey} from '@database/operator/server_data_operator/comparators'; import {shouldUpdateUserRecord} from '@database/operator/server_data_operator/comparators/user'; @@ -82,6 +83,10 @@ describe('*** Operator: User Handlers tests ***', () => { comments: 'never', desktop_notification_sound: 'Hello', push_status: 'online', + calls_desktop_sound: 'true', + calls_mobile_sound: '', + calls_notification_sound: Ringtone.Calm, + calls_mobile_notification_sound: '', }, last_picture_update: 1604686302260, locale: 'en', diff --git a/app/database/operator/server_data_operator/transformers/user.test.ts b/app/database/operator/server_data_operator/transformers/user.test.ts index be4b088d7..9d13f14f2 100644 --- a/app/database/operator/server_data_operator/transformers/user.test.ts +++ b/app/database/operator/server_data_operator/transformers/user.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Ringtone} from '@constants/calls'; import {OperationType} from '@constants/database'; import {transformPreferenceRecord, transformUserRecord} from '@database/operator/server_data_operator/transformers/user'; import {createTestConnection} from '@database/operator/utils/create_test_connection'; @@ -66,6 +67,10 @@ describe('*** USER Prepare Records Test ***', () => { comments: 'never', desktop_notification_sound: 'Hello', push_status: 'online', + calls_desktop_sound: 'true', + calls_mobile_sound: '', + calls_notification_sound: Ringtone.Calm, + calls_mobile_notification_sound: '', }, last_picture_update: 1604686302260, locale: 'en', diff --git a/app/init/app.ts b/app/init/app.ts index 9f690b5b1..bb4362299 100644 --- a/app/init/app.ts +++ b/app/init/app.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {CallsManager} from '@calls/calls_manager'; import DatabaseManager from '@database/manager'; import {getAllServerCredentials} from '@init/credentials'; import {initialLaunch} from '@init/launch'; @@ -47,6 +48,7 @@ export async function initialize() { GlobalEventHandler.init(); ManagedApp.init(); SessionManager.init(); + CallsManager.initialize(); } } diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index 0295086ac..8de4176d0 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -255,7 +255,10 @@ class PushNotifications { this.processNotification(notification); } - completion({alert: false, sound: true, badge: true}); + + // Always play a sound, except when this is a foreground notification about a call + const sound = !(notification.foreground && isCallsStartedMessage(notification.payload)); + completion({alert: false, sound, badge: true}); }; onRemoteNotificationsRegistered = async (event: Registered) => { diff --git a/app/products/calls/calls_manager.ts b/app/products/calls/calls_manager.ts new file mode 100644 index 000000000..a84183d9f --- /dev/null +++ b/app/products/calls/calls_manager.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AppState, Platform} from 'react-native'; + +import {callsOnAppStateChange} from '@calls/state'; + +const initialize = () => { + if (Platform.OS === 'android') { + AppState.addEventListener('blur', () => { + callsOnAppStateChange('inactive'); + }); + AppState.addEventListener('focus', () => { + callsOnAppStateChange('active'); + }); + } else { + AppState.addEventListener('change', callsOnAppStateChange); + } +}; + +export const CallsManager = { + initialize, +}; diff --git a/app/products/calls/components/call_notification/call_notification.tsx b/app/products/calls/components/call_notification/call_notification.tsx index 046461bd4..81491f46b 100644 --- a/app/products/calls/components/call_notification/call_notification.tsx +++ b/app/products/calls/components/call_notification/call_notification.tsx @@ -8,7 +8,7 @@ import {Pressable, Text, View} from 'react-native'; import {switchToChannelById} from '@actions/remote/channel'; import {fetchProfilesInChannel} from '@actions/remote/user'; import {dismissIncomingCall} from '@calls/actions/calls'; -import {removeIncomingCall} from '@calls/state'; +import {playIncomingCallsRinging, removeIncomingCall} from '@calls/state'; import {ChannelType, type IncomingCallNotification} from '@calls/types/calls'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; @@ -17,6 +17,7 @@ import {CALL_NOTIFICATION_BAR_HEIGHT} from '@constants/view'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; +import {useAppState} from '@hooks/device'; import WebsocketManager from '@managers/websocket_manager'; import {getServerDisplayName} from '@queries/app/servers'; import ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; @@ -107,7 +108,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ type Props = { servers: ServersModel[]; incomingCall: IncomingCallNotification; - currentUserId: string; + currentUserId?: string; + userStatus?: string; teammateNameDisplay: string; members?: ChannelMembershipModel[]; onCallsScreen?: boolean; @@ -117,12 +119,14 @@ export const CallNotification = ({ servers, incomingCall, currentUserId, + userStatus, teammateNameDisplay, members, onCallsScreen, }: Props) => { const intl = useIntl(); const serverUrl = useServerUrl(); + const appState = useAppState(); const theme = useTheme(); const style = getStyleSheet(theme); const [serverName, setServerName] = useState(''); @@ -135,6 +139,10 @@ export const CallNotification = ({ } }, []); + useEffect(() => { + playIncomingCallsRinging(incomingCall.serverUrl, incomingCall.callID, userStatus || ''); + }, [incomingCall.serverUrl, incomingCall.callID, appState]); + // We only need to getServerDisplayName once useEffect(() => { async function getName() { diff --git a/app/products/calls/components/call_notification/index.ts b/app/products/calls/components/call_notification/index.ts index e7eb0838a..80fd1c92a 100644 --- a/app/products/calls/components/call_notification/index.ts +++ b/app/products/calls/components/call_notification/index.ts @@ -9,8 +9,7 @@ import {observeAllActiveServers} from '@app/queries/app/servers'; import {CallNotification} from '@calls/components/call_notification/call_notification'; import DatabaseManager from '@database/manager'; import {observeChannelMembers} from '@queries/servers/channel'; -import {observeCurrentUserId} from '@queries/servers/system'; -import {observeTeammateNameDisplay} from '@queries/servers/user'; +import {observeCurrentUser, observeTeammateNameDisplay} from '@queries/servers/user'; import type {IncomingCallNotification} from '@calls/types/calls'; @@ -20,8 +19,15 @@ type OwnProps = { const enhanced = withObservables(['incomingCall'], ({incomingCall}: OwnProps) => { const database = of$(DatabaseManager.serverDatabases[incomingCall.serverUrl]?.database); - const currentUserId = database.pipe( - switchMap((db) => (db ? observeCurrentUserId(db) : of$(''))), + const currentUser = database.pipe( + switchMap((db) => (db ? observeCurrentUser(db) : of$(null))), + ); + const currentUserId = currentUser.pipe( + switchMap((u) => of$(u?.id)), + distinctUntilChanged(), + ); + const userStatus = currentUser.pipe( + switchMap((u) => of$(u?.status)), distinctUntilChanged(), ); const teammateNameDisplay = database.pipe( @@ -36,6 +42,7 @@ const enhanced = withObservables(['incomingCall'], ({incomingCall}: OwnProps) => return { servers: observeAllActiveServers(), currentUserId, + userStatus, teammateNameDisplay, members, }; diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index bd89327c3..6db8a6c75 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -60,12 +60,14 @@ import { DefaultGlobalCallsState, DefaultIncomingCalls, type GlobalCallsState, + type IncomingCalls, } from '@calls/types/calls'; import {License} from '@constants'; import Calls from '@constants/calls'; import DatabaseManager from '@database/manager'; import type {CallJobState, LiveCaptionData} from '@mattermost/calls/lib/types'; +import type UserModel from '@typings/database/models/servers/user'; jest.mock('@calls/alerts'); @@ -1339,11 +1341,12 @@ describe('useCallsState', () => { }; const initialCurrentCallState: CurrentCall | null = null; const initialIncomingCalls = DefaultIncomingCalls; - const expectedIncomingCalls = { + const expectedIncomingCalls: IncomingCalls = { + ...DefaultIncomingCalls, incomingCalls: [{ callID: 'callDM', callerID: 'user-5', - callerModel: {username: 'user-5'}, + callerModel: {username: 'user-5'} as UserModel, channelID: 'channel-private', myUserId: 'myId', serverUrl: 'server1', diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 7b3742875..591efb373 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. import {mosThreshold} from '@mattermost/calls/lib/rtc_monitor'; +import {AppState, type AppStateStatus} from 'react-native'; +import InCallManager from 'react-native-incall-manager'; import {Navigation} from 'react-native-navigation'; import {updateThreadFollowing} from '@actions/remote/thread'; @@ -37,10 +39,11 @@ import {Calls, General, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {getChannelById} from '@queries/servers/channel'; import {getThreadById} from '@queries/servers/thread'; -import {getUserById} from '@queries/servers/user'; +import {getCurrentUser, getUserById} from '@queries/servers/user'; import {isDMorGM} from '@utils/channel'; import {generateId} from '@utils/general'; -import {logDebug} from '@utils/log'; +import {isMainActivity} from '@utils/helpers'; +import {logDebug, logError} from '@utils/log'; import type {CallJobState, LiveCaptionData, UserReactionData} from '@mattermost/calls/lib/types'; @@ -144,7 +147,8 @@ export const processIncomingCalls = async (serverUrl: string, calls: Call[], kee } newIncoming.sort((a, b) => a.startAt - b.startAt); - setIncomingCalls({incomingCalls: newIncoming}); + + setIncomingCalls({...getIncomingCalls(), incomingCalls: newIncoming}); }; const getChannelIdFromCallId = (serverUrl: string, callId: string) => { @@ -162,10 +166,12 @@ export const removeIncomingCall = (serverUrl: string, callId: string, channelId? return; } + stopIncomingCallsRinging(); + const incomingCalls = getIncomingCalls(); const newIncomingCalls = incomingCalls.incomingCalls.filter((ic) => ic.callID !== callId); if (incomingCalls.incomingCalls.length !== newIncomingCalls.length) { - setIncomingCalls({incomingCalls: newIncomingCalls}); + setIncomingCalls({...incomingCalls, incomingCalls: newIncomingCalls}); } let chId = channelId; @@ -184,6 +190,102 @@ export const removeIncomingCall = (serverUrl: string, callId: string, channelId? setCallsState(serverUrl, {...callsState, calls: nextCalls}); }; +let previousAppState: AppStateStatus; + +export const callsOnAppStateChange = async (appState: AppStateStatus) => { + if (appState === previousAppState || !isMainActivity()) { + return; + } + + previousAppState = appState; + switch (appState) { + case 'inactive': + case 'background': + InCallManager.stopRingtone(); + setIncomingCalls({...getIncomingCalls(), currentRingingCallId: undefined}); + break; + } +}; + +const getRingtoneOrNone = async (serverUrl: string) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const user = await getCurrentUser(database); + if (!user) { + // This shouldn't happen, so don't bother localizing and displaying an alert. + return 'none'; + } + + const enabled = user.notifyProps?.calls_mobile_sound ? user.notifyProps.calls_mobile_sound === 'true' : user.notifyProps?.calls_desktop_sound === 'true'; + if (!enabled) { + return 'none'; + } + + let tone = user.notifyProps?.calls_mobile_notification_sound ? user.notifyProps.calls_mobile_notification_sound : user.notifyProps?.calls_notification_sound; + if (!tone) { + tone = Calls.RINGTONE_DEFAULT; + } + return 'calls_' + tone.toLowerCase(); + } catch (error) { + logError('failed to getServerDatabase in getRingtoneOrNone', error); + return 'none'; + } +}; + +const shouldRing = (callId: string, userStatus: string) => { + // Do not ring if we are in the background + if (AppState.currentState !== 'active' || userStatus === General.DND || userStatus === General.OUT_OF_OFFICE) { + return false; + } + + // Do not ring if we are already ringing, or we have no incoming calls, or we have rung for this call already + const incomingCalls = getIncomingCalls(); + if (incomingCalls.currentRingingCallId || incomingCalls.incomingCalls.length === 0 || incomingCalls.callIdHasRung[callId]) { + return false; + } + + // Do not ring if we are in a call + const currentCall = getCurrentCall(); + return !currentCall; +}; + +export const playIncomingCallsRinging = async (serverUrl: string, callId: string, userStatus: string) => { + if (!shouldRing(callId, userStatus)) { + return; + } + + const ringTone = await getRingtoneOrNone(serverUrl); + if (ringTone === 'none') { + return; + } + const incomingCalls = getIncomingCalls(); + setIncomingCalls({ + ...incomingCalls, + currentRingingCallId: callId, + callIdHasRung: {...incomingCalls.callIdHasRung, [callId]: true}, + }); + InCallManager.startRingtone(ringTone, Calls.RINGTONE_VIBRATE_PATTERN); + + setTimeout(() => { + const incoming = getIncomingCalls(); + if (incoming.currentRingingCallId === callId) { + InCallManager.stopRingtone(); + setIncomingCalls({...getIncomingCalls(), currentRingingCallId: undefined}); + } + }, Calls.RING_LENGTH); +}; + +const stopIncomingCallsRinging = () => { + const incomingCalls = getIncomingCalls(); + if (!incomingCalls.currentRingingCallId) { + return; + } + + InCallManager.stopRingtone(); + setIncomingCalls({...incomingCalls, currentRingingCallId: undefined}); +}; + export const setCallForChannel = (serverUrl: string, channelId: string, call?: Call, enabled?: boolean) => { const callsState = getCallsState(serverUrl); let nextEnabled = callsState.enabled; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 913ab3057..1d4dad9cf 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -49,10 +49,13 @@ export type IncomingCallNotification = { export type IncomingCalls = { incomingCalls: IncomingCallNotification[]; + currentRingingCallId?: string; + callIdHasRung: Dictionary; } export const DefaultIncomingCalls: IncomingCalls = { incomingCalls: [], + callIdHasRung: {}, }; export type Call = { diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 2283c7798..b29690ffe 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -226,6 +226,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.SETTINGS_NOTIFICATION_PUSH: screen = withServerDatabase(require('@screens/settings/notification_push').default); break; + case Screens.SETTINGS_NOTIFICATION_CALL: + screen = withServerDatabase(require('@screens/settings/notification_call').default); + break; case Screens.SHARE_FEEDBACK: screen = withServerDatabase(require('@screens/share_feedback').default); break; diff --git a/app/screens/integration_selector/selected_option/index.test.tsx b/app/screens/integration_selector/selected_option/index.test.tsx index 611d1df63..9b80fbcae 100644 --- a/app/screens/integration_selector/selected_option/index.test.tsx +++ b/app/screens/integration_selector/selected_option/index.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {Preferences, View as ViewConstants} from '@constants'; +import {Ringtone} from '@constants/calls'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; @@ -62,6 +63,10 @@ describe('components/integration_selector/selected_option', () => { highlight_keys: '', push: 'mention', push_status: 'ooo', + calls_desktop_sound: 'true', + calls_mobile_sound: '', + calls_notification_sound: Ringtone.Calm, + calls_mobile_notification_sound: '', }, email: 'johndoe@me.com', auth_service: 'dummy', diff --git a/app/screens/integration_selector/selected_options/index.test.tsx b/app/screens/integration_selector/selected_options/index.test.tsx index 74870e5ac..a59afdfb7 100644 --- a/app/screens/integration_selector/selected_options/index.test.tsx +++ b/app/screens/integration_selector/selected_options/index.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {Preferences, View as ViewConstants} from '@constants'; +import {Ringtone} from '@constants/calls'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; @@ -42,6 +43,10 @@ describe('components/integration_selector/selected_options', () => { highlight_keys: '', push: 'mention', push_status: 'ooo', + calls_desktop_sound: 'true', + calls_mobile_sound: '', + calls_notification_sound: Ringtone.Calm, + calls_mobile_notification_sound: '', }, email: 'johndoe@me.com', auth_service: 'dummy', diff --git a/app/screens/settings/config.ts b/app/screens/settings/config.ts index 106505c2e..10fa1c259 100644 --- a/app/screens/settings/config.ts +++ b/app/screens/settings/config.ts @@ -86,6 +86,12 @@ export const NotificationsOptionConfig: Record = { icon: 'cellphone', testID: 'notification_settings.push_notification', }, + call_notification: { + defaultMessage: 'Call Notifications', + i18nId: t('notification_settings.calls'), + icon: 'phone-in-talk', + testID: 'notification_settings.call_notification', + }, email: { defaultMessage: 'Email', i18nId: t('notification_settings.email'), diff --git a/app/screens/settings/notification_call/index.tsx b/app/screens/settings/notification_call/index.tsx new file mode 100644 index 000000000..713a450dc --- /dev/null +++ b/app/screens/settings/notification_call/index.tsx @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import {observeCurrentUser} from '@queries/servers/user'; +import NotificationCall from '@screens/settings/notification_call/notification_call'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + return { + currentUser: observeCurrentUser(database), + }; +}); + +export default withDatabase(enhanced(NotificationCall)); diff --git a/app/screens/settings/notification_call/notification_call.tsx b/app/screens/settings/notification_call/notification_call.tsx new file mode 100644 index 000000000..339749d40 --- /dev/null +++ b/app/screens/settings/notification_call/notification_call.tsx @@ -0,0 +1,192 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import InCallManager from 'react-native-incall-manager'; + +import {updateMe} from '@actions/remote/user'; +import SettingBlock from '@components/settings/block'; +import SettingContainer from '@components/settings/container'; +import SettingOption from '@components/settings/option'; +import SettingSeparator from '@components/settings/separator'; +import {Calls} from '@constants'; +import {Ringtone} from '@constants/calls'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useBackNavigation from '@hooks/navigate_back'; +import {popTopScreen} from '@screens/navigation'; +import {changeOpacity} from '@utils/theme'; +import {getNotificationProps} from '@utils/user'; + +import type UserModel from '@typings/database/models/servers/user'; +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + componentId: AvailableScreens; + currentUser?: UserModel; +}; + +const {footerText} = defineMessages({ + footerText: { + id: 'notification_settings.calls.callsInfo', + defaultMessage: 'Note: silent mode must be off to hear the ringtone preview.', + }, +}); + +const NotificationCall = ({componentId, currentUser}: Props) => { + const serverUrl = useServerUrl(); + const intl = useIntl(); + const theme = useTheme(); + + const notifyProps = useMemo(() => getNotificationProps(currentUser), [currentUser?.notifyProps]); + + const initialCallsMobileSound = useMemo(() => Boolean(notifyProps?.calls_mobile_sound ? notifyProps.calls_mobile_sound === 'true' : notifyProps?.calls_desktop_sound === 'true'), + [/* dependency array should remain empty */]); + const [callsMobileSound, setCallsMobileSound] = useState(initialCallsMobileSound); + const initialCallsMobileNotificationSound = useMemo(() => { + let initialSound = notifyProps?.calls_mobile_notification_sound ? notifyProps.calls_mobile_notification_sound : notifyProps?.calls_notification_sound; + if (!initialSound) { + initialSound = Calls.RINGTONE_DEFAULT; + } + return initialSound; + }, [/* dependency array should remain empty */]); + const [callsMobileNotificationSound, setCallsMobileNotificationSound] = useState(initialCallsMobileNotificationSound); + const [playingRingtone, setPlayingRingtone] = useState(false); + + const close = useCallback(() => { + InCallManager.stopRingtone(); + popTopScreen(componentId); + }, [componentId]); + + const selectOption = useCallback(async (value: string) => { + const tone = 'calls_' + value.toLowerCase(); + + if (value !== callsMobileNotificationSound) { + setCallsMobileNotificationSound(value); + + await InCallManager.stopRingtone(); + await InCallManager.startRingtone(tone, Calls.RINGTONE_VIBRATE_PATTERN); + setPlayingRingtone(true); + return; + } + + if (playingRingtone) { + await InCallManager.stopRingtone(); + setPlayingRingtone(false); + } else { + await InCallManager.startRingtone(tone, Calls.RINGTONE_VIBRATE_PATTERN); + setPlayingRingtone(true); + } + }, [callsMobileNotificationSound, playingRingtone]); + + const selectNotificationOnOff = useCallback(async (on: boolean) => { + setCallsMobileSound(on); + if (!on) { + await InCallManager.stopRingtone(); + } + }, []); + + const canSaveSettings = useCallback(() => { + const cmsString = callsMobileSound ? 'true' : 'false'; + const cms = cmsString !== notifyProps.calls_mobile_sound; + const cmns = callsMobileNotificationSound !== notifyProps.calls_mobile_notification_sound; + return cms || cmns; + }, [notifyProps, callsMobileSound, callsMobileNotificationSound]); + + const saveNotificationSettings = useCallback(() => { + const canSave = canSaveSettings(); + if (canSave) { + const cmsString = callsMobileSound ? 'true' : 'false'; + const notify_props: UserNotifyProps = { + ...notifyProps, + calls_mobile_sound: cmsString, + calls_mobile_notification_sound: callsMobileNotificationSound, + }; + updateMe(serverUrl, {notify_props}); + } + close(); + }, [serverUrl, canSaveSettings, close, notifyProps, callsMobileSound, callsMobileNotificationSound, playingRingtone]); + + useBackNavigation(saveNotificationSettings); + + useAndroidHardwareBackHandler(componentId, saveNotificationSettings); + + return ( + + + {callsMobileSound && ( + + + + + + + + + + + )} + + ); +}; + +export default NotificationCall; diff --git a/app/screens/settings/notifications/notifications.tsx b/app/screens/settings/notifications/notifications.tsx index d01f6f4af..33703d781 100644 --- a/app/screens/settings/notifications/notifications.tsx +++ b/app/screens/settings/notifications/notifications.tsx @@ -2,11 +2,13 @@ // See LICENSE.txt for license information. import React, {useCallback, useMemo} from 'react'; -import {useIntl} from 'react-intl'; +import {defineMessages, useIntl} from 'react-intl'; +import {getCallsConfig} from '@calls/state'; import SettingContainer from '@components/settings/container'; import SettingItem from '@components/settings/item'; import {General, Screens} from '@constants'; +import {useServerUrl} from '@context/server'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {t} from '@i18n'; import {popTopScreen} from '@screens/navigation'; @@ -16,16 +18,24 @@ import {getEmailInterval, getEmailIntervalTexts, getNotificationProps} from '@ut import type UserModel from '@typings/database/models/servers/user'; import type {AvailableScreens} from '@typings/screens/navigation'; -const mentionTexts = { +const mentionTexts = defineMessages({ crtOn: { - id: t('notification_settings.mentions'), + id: 'notification_settings.mentions', defaultMessage: 'Mentions', }, crtOff: { - id: t('notification_settings.mentions_replies'), + id: 'notification_settings.mentions_replies', defaultMessage: 'Mentions and Replies', }, -}; + callsOn: { + id: 'notification_settings.calls_on', + defaultMessage: 'On', + }, + callsOff: { + id: 'notification_settings.calls_off', + defaultMessage: 'Off', + }, +}); type NotificationsProps = { componentId: AvailableScreens; @@ -46,7 +56,9 @@ const Notifications = ({ sendEmailNotifications, }: NotificationsProps) => { const intl = useIntl(); + const serverUrl = useServerUrl(); const notifyProps = useMemo(() => getNotificationProps(currentUser), [currentUser?.notifyProps]); + const callsRingingEnabled = useMemo(() => getCallsConfig(serverUrl).EnableRinging, [serverUrl]); const emailIntervalPref = useMemo(() => getEmailInterval( @@ -75,6 +87,18 @@ const Notifications = ({ gotoSettingsScreen(screen, title); }, []); + const callsNotificationsOn = useMemo(() => Boolean(notifyProps?.calls_mobile_sound ? notifyProps.calls_mobile_sound === 'true' : notifyProps?.calls_desktop_sound === 'true'), + [notifyProps]); + const goToNotificationSettingsCall = useCallback(() => { + const screen = Screens.SETTINGS_NOTIFICATION_CALL; + const title = intl.formatMessage({ + id: 'notification_settings.call_notification', + defaultMessage: 'Call Notifications', + }); + + gotoSettingsScreen(screen, title); + }, []); + const goToNotificationAutoResponder = useCallback(() => { const screen = Screens.SETTINGS_NOTIFICATION_AUTO_RESPONDER; const title = intl.formatMessage({ @@ -112,6 +136,17 @@ const Notifications = ({ onPress={goToNotificationSettingsPush} testID='notification_settings.push_notifications.option' /> + {callsRingingEnabled && + + } (); +- mRingtone = new myMediaPlayer(); ++ storeOriginalAudioSetup(); ++ Map data = new HashMap(); ++ mRingtone = new myMediaPlayer(); + +- data.put("name", "mRingtone"); +- data.put("sourceUri", ringtoneUri); +- data.put("setLooping", true); ++ data.put("name", "mRingtone"); ++ data.put("sourceUri", ringtoneUri); ++ data.put("setLooping", true); + +- //data.put("audioStream", AudioManager.STREAM_RING); // --- lagacy +- data.put("audioUsage", AudioAttributes.USAGE_NOTIFICATION_RINGTONE); // --- USAGE_NOTIFICATION_COMMUNICATION_REQUEST? +- data.put("audioContentType", AudioAttributes.CONTENT_TYPE_MUSIC); ++ //data.put("audioStream", AudioManager.STREAM_RING); // --- lagacy ++ data.put("audioUsage", AudioAttributes.USAGE_NOTIFICATION_RINGTONE); // --- USAGE_NOTIFICATION_COMMUNICATION_REQUEST? ++ data.put("audioContentType", AudioAttributes.CONTENT_TYPE_MUSIC); + +- setMediaPlayerEvents((MediaPlayer) mRingtone, "mRingtone"); ++ setMediaPlayerEvents((MediaPlayer) mRingtone, "mRingtone"); + +- mRingtone.startPlay(data); ++ mRingtone.startPlay(data); + +- if (seconds > 0) { +- mRingtoneCountDownHandler = new Handler(); +- mRingtoneCountDownHandler.postDelayed(new Runnable() { +- public void run() { +- try { +- Log.d(TAG, String.format("mRingtoneCountDownHandler.stopRingtone() timeout after %d seconds", seconds)); +- stopRingtone(); +- } catch(Exception e) { +- Log.d(TAG, "mRingtoneCountDownHandler.stopRingtone() failed."); +- } +- } +- }, seconds * 1000); ++ if (seconds > 0) { ++ mRingtoneCountDownHandler = new Handler(); ++ mRingtoneCountDownHandler.postDelayed(() -> { ++ try { ++ Log.d(TAG, String.format("mRingtoneCountDownHandler.stopRingtone() timeout after %d seconds", seconds)); ++ stopRingtone(); ++ } catch (Exception e) { ++ Log.d(TAG, "mRingtoneCountDownHandler.stopRingtone() failed."); + } +- +- Looper.loop(); +- } catch(Exception e) { +- wakeLockUtils.releasePartialWakeLock(); +- Log.e(TAG, "startRingtone() failed", e); +- } ++ }, seconds * 1000); + } +- }; ++ } catch (Exception e) { ++ wakeLockUtils.releasePartialWakeLock(); ++ Log.e(TAG, "startRingtone() failed", e); ++ promise.reject("startRingtone() failed", e); ++ } + +- thread.start(); ++ promise.resolve(null); + } + ++ + @ReactMethod +- public void stopRingtone() { +- Thread thread = new Thread() { +- @Override +- public void run() { +- try { +- if (mRingtone != null) { +- mRingtone.stopPlay(); +- mRingtone = null; +- restoreOriginalAudioSetup(); +- } +- if (mRingtoneCountDownHandler != null) { +- mRingtoneCountDownHandler.removeCallbacksAndMessages(null); +- mRingtoneCountDownHandler = null; +- } +- } catch (Exception e) { +- Log.d(TAG, "stopRingtone() failed"); +- } +- wakeLockUtils.releasePartialWakeLock(); +- } +- }; ++ public void stopRingtone(Promise promise) { ++ try { ++ stopRingtone(); ++ } catch (Exception e) { ++ promise.reject("stopRingtone() failed", e); ++ } ++ promise.resolve(null); ++ } + +- thread.start(); ++ private void stopRingtone() { ++ try { ++ if (mRingtone != null) { ++ mRingtone.stopPlay(); ++ mRingtone = null; ++ restoreOriginalAudioSetup(); ++ } ++ if (mRingtoneCountDownHandler != null) { ++ mRingtoneCountDownHandler.removeCallbacksAndMessages(null); ++ mRingtoneCountDownHandler = null; ++ } ++ } catch (Exception e) { ++ Log.d(TAG, "stopRingtone() failed"); ++ wakeLockUtils.releasePartialWakeLock(); ++ throw e; ++ } ++ wakeLockUtils.releasePartialWakeLock(); + } + + private void setMediaPlayerEvents(MediaPlayer mp, final String name) { +@@ -1267,6 +1267,32 @@ public class InCallManagerModule extends ReactContextBaseJavaModule implements L + return getAudioUri(type, fileBundle, fileBundleExt, fileSysWithExt, fileSysPath, "bundleBusytoneUri", "defaultBusytoneUri"); + } + ++ private Uri getRingtoneUriFromBundle(final String filename) { ++ if (audioUriMap.containsKey(filename)) { ++ Log.d(TAG, "getAudioUri() using: " + filename); ++ return audioUriMap.get(filename); ++ } ++ ++ int res = 0; ++ ReactContext reactContext = getReactApplicationContext(); ++ if (reactContext == null) { ++ Log.d(TAG, "getAudioUri() reactContext is null"); ++ } else { ++ res = reactContext.getResources().getIdentifier(filename, "raw", mPackageName); ++ } ++ ++ if (res <= 0) { ++ Log.d(TAG, String.format("getAudioUri() %s.%s not found in bundle.", filename, "mp3")); ++ audioUriMap.put(filename, null); ++ return null; ++ } ++ ++ Uri uri = Uri.parse("android.resource://" + mPackageName + "/" + Integer.toString(res)); ++ audioUriMap.put(filename, uri); ++ Log.d(TAG, "getAudioUri() using: " + filename); ++ return uri; ++ } ++ + private Uri getAudioUri(final String _type, final String fileBundle, final String fileBundleExt, final String fileSysWithExt, final String fileSysPath, final String uriBundle, final String uriDefault) { + String type = _type; + if (type.equals("_BUNDLE_")) { +@@ -1300,6 +1326,12 @@ public class InCallManagerModule extends ReactContextBaseJavaModule implements L + final String target = fileSysPath + "/" + type; + Uri _uri = getSysFileUri(target); + if (_uri == null) { ++ Log.d(TAG, "getAudioUri() file not found in system, trying bundle"); ++ _uri = getRingtoneUriFromBundle(type); ++ if (_uri != null) { ++ return _uri; ++ } ++ + Log.d(TAG, "getAudioUri() using user default"); + return getDefaultUserUri(uriDefault); + } else { +diff --git a/node_modules/react-native-incall-manager/index.d.ts b/node_modules/react-native-incall-manager/index.d.ts +index c8ebe9d..5f2ab9e 100644 +--- a/node_modules/react-native-incall-manager/index.d.ts ++++ b/node_modules/react-native-incall-manager/index.d.ts +@@ -37,9 +37,9 @@ declare class InCallManager { + vibrate_pattern: number | number[], + ios_category: string, + seconds: number +- ): void; ++ ): Promise; + +- stopRingtone(): void; ++ stopRingtone(): Promise; + + startProximitySensor(): void; + +diff --git a/node_modules/react-native-incall-manager/index.js b/node_modules/react-native-incall-manager/index.js +index ac5cafb..6887150 100644 +--- a/node_modules/react-native-incall-manager/index.js ++++ b/node_modules/react-native-incall-manager/index.js +@@ -73,14 +73,14 @@ class InCallManager { + _InCallManager.setMicrophoneMute(enable); + } + +- startRingtone(ringtone, vibrate_pattern, ios_category, seconds) { ++ async startRingtone(ringtone, vibrate_pattern, ios_category, seconds) { + ringtone = (typeof ringtone === 'string') ? ringtone : "_DEFAULT_"; + this.vibrate = (Array.isArray(vibrate_pattern)) ? true : false; + ios_category = (ios_category === 'playback') ? 'playback' : "default"; + seconds = (typeof seconds === 'number' && seconds > 0) ? parseInt(seconds) : -1; // --- android only, default looping + + if (Platform.OS === 'android') { +- _InCallManager.startRingtone(ringtone, seconds); ++ await _InCallManager.startRingtone(ringtone, seconds); + } else { + _InCallManager.startRingtone(ringtone, ios_category); + } +@@ -91,11 +91,11 @@ class InCallManager { + } + } + +- stopRingtone() { ++ async stopRingtone() { + if (this.vibrate) { + Vibration.cancel(); + } +- _InCallManager.stopRingtone(); ++ await _InCallManager.stopRingtone(); + } + + startProximitySensor() { +diff --git a/node_modules/react-native-incall-manager/ios/RNInCallManager/RNInCallManager.m b/node_modules/react-native-incall-manager/ios/RNInCallManager/RNInCallManager.m +index 7c54f6e..f861a0f 100644 +--- a/node_modules/react-native-incall-manager/ios/RNInCallManager/RNInCallManager.m ++++ b/node_modules/react-native-incall-manager/ios/RNInCallManager/RNInCallManager.m +@@ -1126,6 +1126,18 @@ - (NSURL *)getRingtoneUri:(NSString *)_type + return uri; + } + ++ ++- (NSURL *)getRingtoneUriFromBundle:(NSString *)filename ++{ ++ NSString *fileBundleExt = @"mp3"; ++ NSLog(@"RNInCallManager.getRingtoneUriFromBundle(): trying to get from bundle: %@.%@", filename, fileBundleExt); ++ NSURL *uriBundle = [[NSBundle mainBundle] URLForResource:filename withExtension:fileBundleExt]; ++ if (uriBundle == nil) { ++ NSLog(@"RNInCallManager.getRingtoneUriFromBundle(): not found in bundle: %@.%@", filename, fileBundleExt); ++ } ++ return uriBundle; ++} ++ + - (NSURL *)getAudioUri:(NSString *)_type + fileBundle:(NSString *)fileBundle + fileBundleExt:(NSString *)fileBundleExt +@@ -1149,11 +1161,15 @@ - (NSURL *)getAudioUri:(NSString *)_type + } + } + +- if (*uriDefault == nil) { +- NSString *target = [NSString stringWithFormat:@"%@/%@", fileSysPath, type]; +- *uriDefault = [self getSysFileUri:target]; ++ // --- Check file every time in case user deleted. ++ NSString *target = [NSString stringWithFormat:@"%@/%@", fileSysPath, type]; ++ NSURL *uri = [self getSysFileUri:target]; ++ ++ if (uri == nil) { ++ uri = [self getRingtoneUriFromBundle:type]; + } +- return *uriDefault; ++ ++ return uri; + } + + - (NSURL *)getSysFileUri:(NSString *)target diff --git a/scripts/postinstall.sh b/scripts/postinstall.sh index 92cf482d4..a2ce17a5f 100755 --- a/scripts/postinstall.sh +++ b/scripts/postinstall.sh @@ -34,4 +34,13 @@ if [ -z "$ASSETS" ]; then exit 1 else echo "Generating app assets" -fi \ No newline at end of file +fi + +SOUNDS="assets/sounds" +if [ -z "$SOUNDS" ]; then + echo "Sound assets not found" + exit 1 +else + echo "Copying sound assets for bundling" + cp $SOUNDS/* "android/app/src/main/res/raw/" +fi diff --git a/test/test_helper.ts b/test/test_helper.ts index e39a4e587..286b94973 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -11,6 +11,7 @@ import nock from 'nock'; import Config from '@assets/config.json'; import {Client} from '@client/rest'; import {ActionType} from '@constants'; +import {Ringtone} from '@constants/calls'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import {PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy'; import DatabaseManager from '@database/manager'; @@ -429,6 +430,10 @@ class TestHelper { mention_keys: '', push: 'default', push_status: 'away', + calls_desktop_sound: 'true', + calls_mobile_notification_sound: '', + calls_mobile_sound: 'true', + calls_notification_sound: '', }; }; @@ -453,6 +458,10 @@ class TestHelper { channel: 'true', mention_keys: '', highlight_keys: '', + calls_desktop_sound: 'true', + calls_mobile_sound: '', + calls_notification_sound: Ringtone.Calm, + calls_mobile_notification_sound: '', }, last_picture_update: 0, position: '', diff --git a/types/api/users.d.ts b/types/api/users.d.ts index 7f6b615f7..20a288055 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -19,6 +19,10 @@ type UserNotifyProps = { user_id?: string; push_threads?: 'all' | 'mention'; email_threads?: 'all' | 'mention'; + calls_desktop_sound: 'true' | 'false'; + calls_notification_sound: string; + calls_mobile_sound: 'true' | 'false' | ''; + calls_mobile_notification_sound: string; }; type UserProfile = { diff --git a/types/modules/react-native-incall-manager.d.ts b/types/modules/react-native-incall-manager.d.ts index 5c5960bf8..fd0450609 100644 --- a/types/modules/react-native-incall-manager.d.ts +++ b/types/modules/react-native-incall-manager.d.ts @@ -44,9 +44,9 @@ declare module 'react-native-incall-manager' { vibrate_pattern?: any[], ios_category?: string, seconds?: number - ): void; + ): Promise; - stopRingtone(): void; + stopRingtone(): Promise; startProximitySensor(): void;