diff --git a/package/expo-package/src/handlers/Audio.ts b/package/expo-package/src/handlers/Audio.ts index 5283131e8f..32b2844750 100644 --- a/package/expo-package/src/handlers/Audio.ts +++ b/package/expo-package/src/handlers/Audio.ts @@ -1,4 +1,4 @@ -import { AudioComponent } from '../optionalDependencies/Video'; +import { AudioComponent, RecordingObject } from '../optionalDependencies/Video'; export enum AndroidOutputFormat { DEFAULT = 0, @@ -212,56 +212,93 @@ export type RecordingOptions = { keepAudioActiveHint?: boolean; }; -export const Audio = AudioComponent - ? { - startRecording: async (recordingOptions: RecordingOptions, onRecordingStatusUpdate) => { - try { - const permissionsGranted = await AudioComponent.getPermissionsAsync().granted; - if (!permissionsGranted) { - await AudioComponent.requestPermissionsAsync(); - } - await AudioComponent.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - }); - const androidOptions = { - audioEncoder: AndroidAudioEncoder.AAC, - extension: '.aac', - outputFormat: AndroidOutputFormat.AAC_ADTS, - }; - const iosOptions = { - audioQuality: IOSAudioQuality.HIGH, - bitRate: 128000, - extension: '.aac', - numberOfChannels: 2, - outputFormat: IOSOutputFormat.MPEG4AAC, - sampleRate: 44100, - }; - const options = { - ...recordingOptions, - android: androidOptions, - ios: iosOptions, - web: {}, - }; +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); - const { recording } = await AudioComponent.Recording.createAsync( - options, - onRecordingStatusUpdate, - ); - return { accessGranted: true, recording }; - } catch (error) { - console.error('Failed to start recording', error); - return { accessGranted: false, recording: null }; - } - }, - stopRecording: async () => { - try { - await AudioComponent.setAudioModeAsync({ - allowsRecordingIOS: false, - }); - } catch (error) { - console.log('Error stopping recoding', error); - } - }, +class _Audio { + recording: typeof RecordingObject | null = null; + + startRecording = async (recordingOptions: RecordingOptions, onRecordingStatusUpdate) => { + try { + const permissions = await AudioComponent.getPermissionsAsync(); + const permissionsStatus = permissions.status; + let permissionsGranted = permissions.granted; + + // If permissions have not been determined yet, ask the user for permissions. + if (permissionsStatus === 'undetermined') { + const newPermissions = await AudioComponent.requestPermissionsAsync(); + permissionsGranted = newPermissions.granted; + } + + // If they are explicitly denied after this, exit early by throwing an error + // that will be caught in the catch block below (as a single source of not + // starting the player). The player would error itself anyway if we did not do + // this, but there's no reason to run the asynchronous calls when we know + // immediately that the player will not be run. + if (!permissionsGranted) { + throw new Error('Missing audio recording permission.'); + } + await AudioComponent.setAudioModeAsync({ + allowsRecordingIOS: true, + playsInSilentModeIOS: true, + }); + const androidOptions = { + audioEncoder: AndroidAudioEncoder.AAC, + extension: '.aac', + outputFormat: AndroidOutputFormat.AAC_ADTS, + }; + const iosOptions = { + audioQuality: IOSAudioQuality.HIGH, + bitRate: 128000, + extension: '.aac', + numberOfChannels: 2, + outputFormat: IOSOutputFormat.MPEG4AAC, + sampleRate: 44100, + }; + const options = { + ...recordingOptions, + android: androidOptions, + ios: iosOptions, + web: {}, + }; + + // This is a band-aid fix for this (still unresolved) issue on Expo's side: + // https://github.com/expo/expo/issues/21782. It only occurs whenever we get + // the permissions dialog and actually select "Allow", causing the player to + // throw an error and send the wrong data downstream. So, if the original + // permissions.status is 'undetermined', meaning we got to here by allowing + // permissions - we sleep for 500ms before proceeding. Any subsequent calls + // to startRecording() will not invoke the sleep. + if (permissionsStatus === 'undetermined') { + await sleep(500); + } + + const { recording } = await AudioComponent.Recording.createAsync( + options, + onRecordingStatusUpdate, + ); + this.recording = recording; + return { accessGranted: true, recording }; + } catch (error) { + console.error('Failed to start recording', error); + return { accessGranted: false, recording: null }; + } + }; + stopRecording = async () => { + try { + await this.recording.stopAndUnloadAsync(); + await AudioComponent.setAudioModeAsync({ + allowsRecordingIOS: false, + }); + this.recording = null; + } catch (error) { + console.log('Error stopping recoding', error); } - : null; + }; +} + +export const Audio = AudioComponent ? new _Audio() : null; diff --git a/package/expo-package/src/optionalDependencies/Video.ts b/package/expo-package/src/optionalDependencies/Video.ts index 75daa8c807..4505c97b69 100644 --- a/package/expo-package/src/optionalDependencies/Video.ts +++ b/package/expo-package/src/optionalDependencies/Video.ts @@ -1,9 +1,11 @@ let VideoComponent; let AudioComponent; +let RecordingObject; try { const audioVideoPackage = require('expo-av'); VideoComponent = audioVideoPackage.Video; AudioComponent = audioVideoPackage.Audio; + RecordingObject = audioVideoPackage.RecordingObject; } catch (e) { // do nothing } @@ -14,4 +16,4 @@ if (!VideoComponent || !AudioComponent) { ); } -export { AudioComponent, VideoComponent }; +export { AudioComponent, VideoComponent, RecordingObject }; diff --git a/package/jest-setup.js b/package/jest-setup.js index 2bf6b4c61e..26d266bd93 100644 --- a/package/jest-setup.js +++ b/package/jest-setup.js @@ -17,7 +17,9 @@ export const setNetInfoFetchMock = (fn) => { registerNativeHandlers({ Audio: { startPlayer: jest.fn(), + startRecording: jest.fn(() => ({ accessGranted: true, recording: 'some-recording-path' })), stopPlayer: jest.fn(), + stopRecording: jest.fn(), }, compressImage: () => null, deleteFile: () => null, diff --git a/package/native-package/src/optionalDependencies/Audio.ts b/package/native-package/src/optionalDependencies/Audio.ts index 7de216b604..6f1775998b 100644 --- a/package/native-package/src/optionalDependencies/Audio.ts +++ b/package/native-package/src/optionalDependencies/Audio.ts @@ -168,74 +168,85 @@ const verifyAndroidPermissions = async () => { return true; }; -export const Audio = AudioRecorderPackage - ? { - pausePlayer: async () => { - await audioRecorderPlayer.pausePlayer(); - }, - resumePlayer: async () => { - await audioRecorderPlayer.resumePlayer(); - }, - startPlayer: async (uri, _, onPlaybackStatusUpdate) => { - try { - const playback = await audioRecorderPlayer.startPlayer(uri); - console.log({ playback }); - audioRecorderPlayer.addPlayBackListener((status) => { - onPlaybackStatusUpdate(status); - }); - } catch (error) { - console.log('Error starting player', error); - } - }, - startRecording: async (options: RecordingOptions, onRecordingStatusUpdate) => { - if (Platform.OS === 'android') { - try { - await verifyAndroidPermissions(); - } catch (err) { - console.warn('Audio Recording Permissions error', err); - return; - } - } - try { - const path = Platform.select({ - android: `${RNFS.CachesDirectoryPath}/sound.aac`, - ios: 'sound.aac', - }); - const audioSet = { - AudioEncoderAndroid: AudioEncoderAndroidType.AAC, - AudioSourceAndroid: AudioSourceAndroidType.MIC, - AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high, - AVFormatIDKeyIOS: AVEncodingOption.aac, - AVModeIOS: AVModeIOSOption.measurement, - AVNumberOfChannelsKeyIOS: 2, - OutputFormatAndroid: OutputFormatAndroidType.AAC_ADTS, - }; - const recording = await audioRecorderPlayer.startRecorder( - path, - audioSet, - options?.isMeteringEnabled, - ); +class _Audio { + pausePlayer = async () => { + await audioRecorderPlayer.pausePlayer(); + }; + resumePlayer = async () => { + await audioRecorderPlayer.resumePlayer(); + }; + startPlayer = async (uri, _, onPlaybackStatusUpdate) => { + try { + const playback = await audioRecorderPlayer.startPlayer(uri); + console.log({ playback }); + audioRecorderPlayer.addPlayBackListener((status) => { + onPlaybackStatusUpdate(status); + }); + } catch (error) { + console.log('Error starting player', error); + } + }; + startRecording = async (options: RecordingOptions, onRecordingStatusUpdate) => { + if (Platform.OS === 'android') { + try { + await verifyAndroidPermissions(); + } catch (err) { + console.warn('Audio Recording Permissions error', err); + return; + } + } + try { + const path = Platform.select({ + android: `${RNFS.CachesDirectoryPath}/sound.aac`, + ios: 'sound.aac', + }); + const audioSet = { + AudioEncoderAndroid: AudioEncoderAndroidType.AAC, + AudioSourceAndroid: AudioSourceAndroidType.MIC, + AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high, + AVFormatIDKeyIOS: AVEncodingOption.aac, + AVModeIOS: AVModeIOSOption.measurement, + AVNumberOfChannelsKeyIOS: 2, + OutputFormatAndroid: OutputFormatAndroidType.AAC_ADTS, + }; + const recording = await audioRecorderPlayer.startRecorder( + path, + audioSet, + options?.isMeteringEnabled, + ); - audioRecorderPlayer.addRecordBackListener((status) => { - onRecordingStatusUpdate(status); - }); - return { accessGranted: true, recording }; - } catch (error) { - console.error('Failed to start recording', error); - return { accessGranted: false, recording: null }; - } - }, - stopPlayer: async () => { - try { - await audioRecorderPlayer.stopPlayer(); - audioRecorderPlayer.removePlayBackListener(); - } catch (error) { - console.log(error); - } - }, - stopRecording: async () => { - await audioRecorderPlayer.stopRecorder(); - audioRecorderPlayer.removeRecordBackListener(); - }, + audioRecorderPlayer.addRecordBackListener((status) => { + onRecordingStatusUpdate(status); + }); + return { accessGranted: true, recording }; + } catch (error) { + console.error('Failed to start recording', error); + // There is currently a bug in react-native-audio-recorder-player and we + // need to do this until it gets fixed. More information can be found here: + // https://github.com/hyochan/react-native-audio-recorder-player/pull/625 + // eslint-disable-next-line no-underscore-dangle + audioRecorderPlayer._isRecording = false; + // eslint-disable-next-line no-underscore-dangle + audioRecorderPlayer._hasPausedRecord = false; + return { accessGranted: false, recording: null }; } - : null; + }; + stopPlayer = async () => { + try { + await audioRecorderPlayer.stopPlayer(); + audioRecorderPlayer.removePlayBackListener(); + } catch (error) { + console.log(error); + } + }; + stopRecording = async () => { + try { + await audioRecorderPlayer.stopRecorder(); + audioRecorderPlayer.removeRecordBackListener(); + } catch (error) { + console.log(error); + } + }; +} + +export const Audio = AudioRecorderPackage ? new _Audio() : null; diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index b14cd74d2e..20a9cf52fb 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -2,15 +2,14 @@ import React, { useEffect, useMemo, useState } from 'react'; import { NativeSyntheticEvent, StyleSheet, TextInputFocusEventData, View } from 'react-native'; import { - GestureEvent, - PanGestureHandler, + Gesture, + GestureDetector, PanGestureHandlerEventPayload, } from 'react-native-gesture-handler'; import Animated, { Extrapolation, interpolate, runOnJS, - useAnimatedGestureHandler, useAnimatedStyle, useSharedValue, withSpring, @@ -104,6 +103,7 @@ type MessageInputPropsWithContext< | 'asyncMessagesLockDistance' | 'asyncMessagesMinimumPressDuration' | 'asyncMessagesSlideToCancelDistance' + | 'asyncMessagesMultiSendEnabled' | 'asyncUploads' | 'AudioRecorder' | 'AudioRecordingInProgress' @@ -168,6 +168,7 @@ const MessageInputWithContext = < asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, + asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, asyncUploads, AttachmentPickerSelectionBar, @@ -624,19 +625,16 @@ const MessageInputWithContext = < const resetAudioRecording = async () => { await deleteVoiceRecording(); - micPositionX.value = 0; }; const micLockHandler = () => { setMicLocked(true); - micPositionY.value = 0; triggerHaptic('impactMedium'); }; - const handleMicGestureEvent = useAnimatedGestureHandler< - GestureEvent - >({ - onActive: (event) => { + const panGestureMic = Gesture.Pan() + .activateAfterLongPress(asyncMessagesMinimumPressDuration + 100) + .onChange((event: PanGestureHandlerEventPayload) => { const newPositionX = event.translationX; const newPositionY = event.translationY; @@ -646,27 +644,38 @@ const MessageInputWithContext = < if (newPositionY <= 0 && newPositionY >= Y_AXIS_POSITION) { micPositionY.value = newPositionY; } - }, - onFinish: () => { - if (micPositionY.value > Y_AXIS_POSITION / 2) { + }) + .onEnd(() => { + const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2; + const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2; + + if (belowThresholdY && belowThresholdX) { micPositionY.value = withSpring(0); - } else { + micPositionX.value = withSpring(0); + if (recordingStatus === 'recording') { + runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); + } + return; + } + + if (!belowThresholdY) { micPositionY.value = withSpring(Y_AXIS_POSITION); runOnJS(micLockHandler)(); } - if (micPositionX.value > X_AXIS_POSITION / 2) { - micPositionX.value = withSpring(0); - } else { + + if (!belowThresholdX) { micPositionX.value = withSpring(X_AXIS_POSITION); runOnJS(resetAudioRecording)(); } - }, - onStart: () => { + + micPositionX.value = 0; + micPositionY.value = 0; + }) + .onStart(() => { micPositionX.value = 0; micPositionY.value = 0; runOnJS(setMicLocked)(false); - }, - }); + }); const animatedStyles = { lockIndicator: useAnimatedStyle(() => ({ @@ -720,21 +729,20 @@ const MessageInputWithContext = < micLocked={micLocked} style={animatedStyles.lockIndicator} /> - {micLocked && - (recordingStatus === 'stopped' ? ( - - ) : ( - - ))} + {recordingStatus === 'stopped' ? ( + + ) : micLocked ? ( + + ) : null} )} @@ -818,10 +826,7 @@ const MessageInputWithContext = < ))} {audioRecordingEnabled && !micLocked && ( - + - + )} )} @@ -1042,6 +1047,7 @@ export const MessageInput = < asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, + asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, asyncUploads, AudioRecorder, @@ -1118,6 +1124,7 @@ export const MessageInput = < asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, + asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, asyncUploads, AttachmentPickerSelectionBar, diff --git a/package/src/components/MessageInput/__tests__/MessageInput.test.js b/package/src/components/MessageInput/__tests__/MessageInput.test.js index 070b924947..a62207c531 100644 --- a/package/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/package/src/components/MessageInput/__tests__/MessageInput.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { Alert } from 'react-native'; -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { cleanup, fireEvent, render, userEvent, waitFor } from '@testing-library/react-native'; import * as AttachmentPickerUtils from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -16,6 +16,7 @@ import { import { generateChannelResponse } from '../../../mock-builders/generator/channel'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Audio } from '../../../native'; import { AttachmentPickerSelectionBar } from '../../AttachmentPicker/components/AttachmentPickerSelectionBar'; import { CameraSelectorIcon } from '../../AttachmentPicker/components/CameraSelectorIcon'; import { FileSelectorIcon } from '../../AttachmentPicker/components/FileSelectorIcon'; @@ -127,4 +128,62 @@ describe('MessageInput', () => { // Both for files and for images triggered in one test itself. expect(Alert.alert).toHaveBeenCalledTimes(4); }); + + it('should start the audio recorder on long press and cleanup on unmount', async () => { + jest.clearAllMocks(); + + await initializeChannel(generateChannelResponse()); + const userBot = userEvent.setup(); + + const { queryByTestId, unmount } = render( + + + + + , + ); + + await userBot.longPress(queryByTestId('audio-button'), { duration: 1000 }); + + await waitFor(() => { + expect(Audio.startRecording).toHaveBeenCalledTimes(1); + expect(Audio.stopRecording).not.toHaveBeenCalled(); + expect(queryByTestId('recording-active-container')).toBeTruthy(); + expect(Alert.alert).not.toHaveBeenCalledWith('Hold to start recording.'); + }); + + unmount(); + + await waitFor(() => { + expect(Audio.stopRecording).toHaveBeenCalledTimes(1); + // once when starting the recording, once on unmount + expect(Audio.stopPlayer).toHaveBeenCalledTimes(2); + }); + }); + + it('should trigger an alert if a normal press happened on audio recording', async () => { + jest.clearAllMocks(); + + await initializeChannel(generateChannelResponse()); + const userBot = userEvent.setup(); + + const { queryByTestId } = render( + + + + + , + ); + + await userBot.press(queryByTestId('audio-button')); + + await waitFor(() => { + expect(Audio.startRecording).not.toHaveBeenCalled(); + expect(Audio.stopRecording).not.toHaveBeenCalled(); + expect(queryByTestId('recording-active-container')).not.toBeTruthy(); + // This is sort of a brittle test, but there doesn't seem to be another way + // to target alerts. The reason why it's here is because we had a bug with it. + expect(Alert.alert).toHaveBeenCalledWith('Hold to start recording.'); + }); + }); }); diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx index b092960c5a..9b86c8b97a 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx @@ -180,7 +180,7 @@ const AudioRecorderWithContext = < } else { return ( <> - + {recordingDuration ? dayjs.duration(recordingDuration).format('mm:ss') : null} diff --git a/package/src/components/MessageInput/hooks/useAudioController.tsx b/package/src/components/MessageInput/hooks/useAudioController.tsx index 4580e32184..bc8c438ff2 100644 --- a/package/src/components/MessageInput/hooks/useAudioController.tsx +++ b/package/src/components/MessageInput/hooks/useAudioController.tsx @@ -38,10 +38,12 @@ export const useAudioController = () => { // For playback support in Expo CLI apps const soundRef = useRef(null); - // Effect to stop the player when the component unmounts + // This effect stop the player from playing and stops audio recording on + // the audio SDK side on unmount. useEffect( () => () => { stopVoicePlayer(); + stopSDKVoiceRecording(); }, [], ); @@ -163,7 +165,6 @@ export const useAudioController = () => { */ const startVoiceRecording = async () => { if (!Audio) return; - setRecordingStatus('recording'); const recordingInfo = await Audio.startRecording( { isMeteringEnabled: true, @@ -178,6 +179,7 @@ export const useAudioController = () => { recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60); } setRecording(recording); + setRecordingStatus('recording'); await stopVoicePlayer(); } else { setPermissionsGranted(false); @@ -186,22 +188,21 @@ export const useAudioController = () => { } }; + /** + * A function that takes care of stopping the voice recording from the library's + * side only. Meant to be used as a pure function (during unmounting for instance) + * hence this approach. + */ + const stopSDKVoiceRecording = async () => { + if (!Audio) return; + await Audio.stopRecording(); + }; + /** * Function to stop voice recording. */ const stopVoiceRecording = async () => { - if (!Audio) return; - if (recording) { - // For Expo CLI - if (typeof recording !== 'string') { - await recording.stopAndUnloadAsync(); - await Audio.stopRecording(); - } - // For RN CLI - else { - await Audio.stopRecording(); - } - } + await stopSDKVoiceRecording(); setRecordingStatus('stopped'); };