Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,14 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps
});

const onTouchGestureEnd = useStableCallback(() => {
if (cancellableDuration) {
onEarlyReleaseHandler();
return;
}
if (status === 'recording') {
if (cancellableDuration) {
onEarlyReleaseHandler();
} else {
uploadVoiceRecording(asyncMessagesMultiSendEnabled);
}
uploadVoiceRecording(asyncMessagesMultiSendEnabled);
} else {
resetAudioRecording();
}
});

Expand Down
16 changes: 8 additions & 8 deletions package/src/components/MessageInput/hooks/useAudioRecorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ export const useAudioRecorder = ({
* side only. Meant to be used as a pure function (during unmounting for instance)
* hence this approach.
*/
const stopVoiceRecording = useCallback(async () => {
const { status } = audioRecorderManager.state.getLatestValue();
if (status !== 'recording') return;
await audioRecorderManager.stopRecording();
}, [audioRecorderManager]);
const stopVoiceRecording = useCallback(
async (withDelete?: boolean) => {
await audioRecorderManager.stopRecording(withDelete);
},
[audioRecorderManager],
);

// This effect stop the player from playing and stops audio recording on
// the audio SDK side on unmount.
Expand Down Expand Up @@ -62,9 +63,8 @@ export const useAudioRecorder = ({
* Function to delete voice recording.
*/
const deleteVoiceRecording = useCallback(async () => {
await stopVoiceRecording();
audioRecorderManager.reset();
}, [audioRecorderManager, stopVoiceRecording]);
await stopVoiceRecording(true);
}, [stopVoiceRecording]);

/**
* Function to upload or send voice recording.
Expand Down
219 changes: 219 additions & 0 deletions package/src/state-store/__tests__/audio-recorder-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { AudioReturnType, NativeHandlers } from '../../native';
import { AudioRecorderManager } from '../audio-recorder-manager';

const createDeferred = <T>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, reject, resolve };
};

const getMockRecording = () =>
({
getStatusAsync: jest.fn(),
getURI: jest.fn(),
pauseAsync: jest.fn(),
recording: 'recording-id',
setProgressUpdateInterval: jest.fn(),
stopAndUnloadAsync: jest.fn(),
}) as const;

describe('AudioRecorderManager race conditions', () => {
const originalAudioHandler = NativeHandlers.Audio;

beforeEach(() => {
jest.clearAllMocks();
});

afterEach(() => {
NativeHandlers.Audio = originalAudioHandler;
});

it('keeps initial recorder state', () => {
const manager = new AudioRecorderManager();
expect(manager.state.getLatestValue()).toEqual({
duration: 0,
isStarting: false,
micLocked: false,
recording: undefined,
status: 'idle',
waveformData: [],
});
});

it('starts successfully and transitions to recording only after native start resolves', async () => {
const recording = getMockRecording();
const startRecording = jest.fn().mockResolvedValue({
accessGranted: true,
recording,
} satisfies AudioReturnType);
const stopRecording = jest.fn().mockResolvedValue(undefined);

NativeHandlers.Audio = {
audioRecordingConfiguration: {},
startRecording,
stopRecording,
};

const manager = new AudioRecorderManager();
const startPromise = manager.startRecording();

expect(manager.state.getLatestValue().isStarting).toBe(true);
await startPromise;

const latest = manager.state.getLatestValue();
expect(latest.isStarting).toBe(false);
expect(latest.status).toBe('recording');
expect(latest.recording).toBe(recording);
expect(recording.setProgressUpdateInterval).toHaveBeenCalledWith(expect.any(Number));
});

it('stops during in-flight start and does not enter recording when start resolves', async () => {
const recording = getMockRecording();
const deferred = createDeferred<AudioReturnType>();
const startRecording = jest.fn().mockImplementation(() => deferred.promise);
const stopRecording = jest.fn().mockResolvedValue(undefined);

NativeHandlers.Audio = {
audioRecordingConfiguration: {},
startRecording,
stopRecording,
};

const manager = new AudioRecorderManager();
const startPromise = manager.startRecording();

expect(manager.state.getLatestValue().isStarting).toBe(true);

await manager.stopRecording();
expect(manager.state.getLatestValue()).toEqual({
duration: 0,
isStarting: false,
micLocked: false,
recording: undefined,
status: 'idle',
waveformData: [],
});

deferred.resolve({ accessGranted: true, recording });
await startPromise;

expect(stopRecording).toHaveBeenCalledTimes(1);
expect(manager.state.getLatestValue().status).toBe('idle');
expect(manager.state.getLatestValue().recording).toBeUndefined();
});

it('does not call native stop after pending stop if start resolves without access', async () => {
const deferred = createDeferred<AudioReturnType>();
const startRecording = jest.fn().mockImplementation(() => deferred.promise);
const stopRecording = jest.fn().mockResolvedValue(undefined);

NativeHandlers.Audio = {
audioRecordingConfiguration: {},
startRecording,
stopRecording,
};

const manager = new AudioRecorderManager();
const startPromise = manager.startRecording();
await manager.stopRecording();

deferred.resolve({ accessGranted: false, recording: undefined });
await startPromise;

expect(stopRecording).not.toHaveBeenCalled();
expect(manager.state.getLatestValue().status).toBe('idle');
});

it('resets state when native start throws', async () => {
const startRecording = jest.fn().mockRejectedValue(new Error('start failed'));
const stopRecording = jest.fn().mockResolvedValue(undefined);

NativeHandlers.Audio = {
audioRecordingConfiguration: {},
startRecording,
stopRecording,
};

const manager = new AudioRecorderManager();
const result = await manager.startRecording();

expect(result).toBe(false);
expect(manager.state.getLatestValue()).toEqual({
duration: 0,
isStarting: false,
micLocked: false,
recording: undefined,
status: 'idle',
waveformData: [],
});
});

it('does not let stale start completion overwrite newer recording session', async () => {
const recording1 = getMockRecording();
const recording2 = getMockRecording();
const deferred1 = createDeferred<AudioReturnType>();
const deferred2 = createDeferred<AudioReturnType>();

const startRecording = jest
.fn()
.mockImplementationOnce(() => deferred1.promise)
.mockImplementationOnce(() => deferred2.promise);
const stopRecording = jest.fn().mockResolvedValue(undefined);

NativeHandlers.Audio = {
audioRecordingConfiguration: {},
startRecording,
stopRecording,
};

const manager = new AudioRecorderManager();
const firstStart = manager.startRecording();
await manager.stopRecording();
const secondStart = manager.startRecording();

deferred1.resolve({ accessGranted: true, recording: recording1 });
await firstStart;

// First (stale) completion should not put stale recording into state.
expect(manager.state.getLatestValue().recording).toBeUndefined();
expect(manager.state.getLatestValue().status).toBe('idle');

deferred2.resolve({ accessGranted: true, recording: recording2 });
await secondStart;

expect(manager.state.getLatestValue().recording).toBe(recording2);
expect(manager.state.getLatestValue().status).toBe('recording');
});

it('stopRecording with delete clears state instead of setting stopped', async () => {
const recording = getMockRecording();
const startRecording = jest.fn().mockResolvedValue({
accessGranted: true,
recording,
} satisfies AudioReturnType);
const stopRecording = jest.fn().mockResolvedValue(undefined);

NativeHandlers.Audio = {
audioRecordingConfiguration: {},
startRecording,
stopRecording,
};

const manager = new AudioRecorderManager();
await manager.startRecording();
await manager.stopRecording(true);

expect(manager.state.getLatestValue()).toEqual({
duration: 0,
isStarting: false,
micLocked: false,
recording: undefined,
status: 'idle',
waveformData: [],
});
});
});
Loading
Loading