From e63c897ec274a5421fb22c9ed8b29bcae4f0fa9e Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:51:20 +0300 Subject: [PATCH] Ignore a second voice recording start while one is already running The composer emits Start whenever the voice message state is Idle, and the state only leaves Idle once the recorder has produced its first audio buffer. A second tap inside that window started a second recording, which overwrote the output file, re-initialised the encoder while the first one was still encoding, and orphaned the first job together with its AudioReader. The orphan kept the microphone open and kept emitting Recording for the rest of the session, so Stop and Delete appeared to do nothing until the room was left and re-entered, and re-initialising the encoder under an active encode could crash natively. DefaultVoiceRecorder.startRecord now runs under the mutex it already owns and returns early when a recording job exists, so the second start is a no-op: no file, no encoder re-init and no second AudioReader. Relates to https://github.com/element-hq/element-x-android/issues/5780 --- .../impl/DefaultVoiceRecorder.kt | 16 ++++++--- .../impl/DefaultVoiceRecorderTest.kt | 33 +++++++++++++++++-- .../test/FakeAudioReaderFactory.kt | 5 +++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt index 5e4c9ae132..4dd68d25e7 100644 --- a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt @@ -71,14 +71,22 @@ class DefaultVoiceRecorder( override val state: StateFlow = _state @RequiresPermission(Manifest.permission.RECORD_AUDIO) - override suspend fun startRecord() { + override suspend fun startRecord() = lock.withLock { + // The composer can emit a second Start before the first recording has produced its first + // buffer, i.e. before the state flips away from Idle. Without this guard the second call + // orphans the first job and its AudioReader, which keeps the microphone open and keeps + // emitting Recording forever, so Stop and Delete appear to do nothing. + // See https://github.com/element-hq/element-x-android/issues/5780 + if (recordingJob != null) { + Timber.w("Voice recorder is already recording, ignoring this start") + return@withLock + } + Timber.i("Voice recorder started recording") outputFile = fileManager.createFile() .also(encoder::init) - lock.withLock { - levels.clear() - } + levels.clear() val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it } diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt index e3ff6f9198..3a65d612bb 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt @@ -39,6 +39,7 @@ import kotlin.time.TestTimeSource class DefaultVoiceRecorderTest { private val fakeFileSystem = FakeFileSystem() private val timeSource = TestTimeSource() + private val audioReaderFactory = FakeAudioReaderFactory(audio = AUDIO) @Test fun `it emits the initial state`() = runTest { @@ -109,6 +110,34 @@ class DefaultVoiceRecorderTest { } } + @Test + fun `when startRecord is called twice, the second call is ignored`() = runTest { + val voiceRecorder = createDefaultVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + + // Two taps landing before the state has flipped away from Idle. + voiceRecorder.startRecord() + voiceRecorder.startRecord() + assertThat(audioReaderFactory.createdCount).isEqualTo(1) + + skipItems(1) + timeSource += 5.seconds + skipItems(2) + voiceRecorder.stopRecord() + assertThat(awaitItem()).isEqualTo( + VoiceRecorderState.Finished( + file = File(FILE_PATH), + mimeType = MimeTypes.Ogg, + waveform = List(100) { 1f }, + duration = 5.seconds, + ) + ) + // An orphaned recording job would keep emitting Recording after the stop. + expectNoEvents() + } + } + @Test fun `when cancelled, it deletes the file`() = runTest { val voiceRecorder = createDefaultVoiceRecorder() @@ -128,9 +157,7 @@ class DefaultVoiceRecorderTest { return DefaultVoiceRecorder( dispatchers = testCoroutineDispatchers(), timeSource = timeSource, - audioReaderFactory = FakeAudioReaderFactory( - audio = AUDIO, - ), + audioReaderFactory = audioReaderFactory, encoder = FakeEncoder(fakeFileSystem), config = AudioConfig( format = audioFormat, diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt index d4bd241aaa..2c29b2635e 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt @@ -16,7 +16,12 @@ import io.element.android.libraries.voicerecorder.impl.audio.AudioReader class FakeAudioReaderFactory( private val audio: List