diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index f7430920a9e..6b237b5136a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -279,10 +279,7 @@ public class MessageComposerViewModel( public fun seekRecordingTo(progress: Float): Unit = messageComposerController.seekRecordingTo(progress) - public fun sendRecording() { - completeRecording() - sendMessage(buildNewMessage(input.value, selectedAttachments.value)) - } + public fun sendRecording(): Unit = messageComposerController.sendRecording() /** * Disposes the inner [MessageComposerController]. diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt index d5d56a20e14..52d7c911eea 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt @@ -22,14 +22,18 @@ import io.getstream.chat.android.client.audio.ProgressData import io.getstream.chat.android.client.audio.audioHash import io.getstream.chat.android.client.extensions.EXTRA_WAVEFORM_DATA import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider +import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState import io.getstream.chat.android.ui.common.state.messages.composer.copy import io.getstream.log.StreamLog import io.getstream.log.TaggedLogger +import io.getstream.result.Error +import io.getstream.result.Result import io.getstream.sdk.chat.audio.recording.StreamMediaRecorder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.util.Date import kotlin.math.abs @@ -41,7 +45,6 @@ import kotlin.math.sqrt /** * Controller responsible for recording audio messages. * - * @param channelId The ID of the channel we're chatting in. * @param audioPlayer The audio player used to play audio messages. * @param mediaRecorder The media recorder used to record audio messages. * @param fileToUri Coverts [File] into Uri like string. @@ -49,7 +52,6 @@ import kotlin.math.sqrt * @param scope MessageComposerController's scope. */ internal class AudioRecordingController( - private val channelId: String, private val audioPlayer: AudioPlayer, private val mediaRecorder: StreamMediaRecorder, private val fileToUri: (File) -> String, @@ -158,7 +160,7 @@ internal class AudioRecordingController( } } - public fun startRecording(offset: Pair? = null) { + suspend fun startRecording(offset: Pair? = null) { val state = this.recordingState.value if (state !is RecordingState.Idle) { logger.w { "[startRecording] rejected (state is not Idle): $state" } @@ -166,11 +168,14 @@ internal class AudioRecordingController( } logger.i { "[startRecording] state: $state" } val recordingName = "audio_recording_${Date()}.aac" - mediaRecorder.startAudioRecording(recordingName, realPollingInterval.toLong()) + // Call on Dispatchers.IO because it accesses file system + withContext(DispatcherProvider.IO) { + mediaRecorder.startAudioRecording(recordingName, realPollingInterval.toLong()) + } setState(RecordingState.Hold(offset = offset ?: RecordingState.Hold.ZeroOffset)) } - public fun holdRecording(offset: Pair? = null) { + fun holdRecording(offset: Pair? = null) { val state = this.recordingState.value if (state !is RecordingState.Hold) { logger.w { "[holdRecording] rejected (state is not Hold): $state" } @@ -184,7 +189,7 @@ internal class AudioRecordingController( setState(state.copy(offset = offset)) } - public fun lockRecording() { + fun lockRecording() { val state = this.recordingState.value if (state !is RecordingState.Hold) { logger.w { "[lockRecording] rejected (state is not Hold): $state" } @@ -194,10 +199,10 @@ internal class AudioRecordingController( setState(RecordingState.Locked(state.durationInMs, state.waveform)) } - public fun cancelRecording() { + fun cancelRecording() { val state = this.recordingState.value if (state is RecordingState.Idle) { - logger.w { "[cancelRecording] rejected (state is not Idle)" } + logger.w { "[cancelRecording] rejected (state is Idle)" } return } logger.i { "[cancelRecording] state: $state" } @@ -209,7 +214,7 @@ internal class AudioRecordingController( setState(RecordingState.Idle) } - public fun toggleRecordingPlayback() { + fun toggleRecordingPlayback() { val state = this.recordingState.value if (state !is RecordingState.Overview) { logger.v { "[toggleRecordingPlayback] rejected (state is not Locked): $state" } @@ -278,14 +283,17 @@ internal class AudioRecordingController( } } - public fun stopRecording() { + suspend fun stopRecording() { val state = this.recordingState.value if (state !is RecordingState.Locked) { logger.w { "[stopRecording] rejected (state is not Locked): $state" } return } logger.i { "[stopRecording] no args: $state" } - val result = mediaRecorder.stopRecording() + // Call on Dispatchers.IO because it accesses file system + val result = withContext(DispatcherProvider.IO) { + mediaRecorder.stopRecording() + } if (result.isFailure) { logger.e { "[stopRecording] failed: ${result.errorOrNull()}" } clearData() @@ -300,7 +308,7 @@ internal class AudioRecordingController( setState(RecordingState.Overview(recorded.durationInMs, normalized, recorded.attachment)) } - public fun seekRecordingTo(progress: Float) { + fun seekRecordingTo(progress: Float) { val state = this.recordingState.value if (state !is RecordingState.Overview) { logger.w { "[seekRecordingTo] rejected (state is not Overview)" } @@ -317,7 +325,7 @@ internal class AudioRecordingController( setState(state.copy(playingProgress = progress)) } - public fun pauseRecording() { + fun pauseRecording() { val state = this.recordingState.value if (state !is RecordingState.Overview) { logger.w { "[pauseRecording] rejected (state is not Overview)" } @@ -328,7 +336,52 @@ internal class AudioRecordingController( setState(state.copy(isPlaying = false)) } - public fun completeRecording() { + suspend fun completeRecordingSync(): Result { + val state = this.recordingState.value + logger.w { "[completeRecordingSync] state: $state" } + if (state is RecordingState.Idle) { + logger.w { "[completeRecordingSync] rejected (state is Idle)" } + return Result.Failure(Error.GenericError("Recording is in Idle state")) + } + if (state is RecordingState.Overview) { + logger.d { "[completeRecordingSync] completing from Overview state" } + clearData() + val attachment = state.attachment.copy( + extraData = state.attachment.extraData + mapOf( + EXTRA_WAVEFORM_DATA to state.waveform, + ), + ) + setState(RecordingState.Idle) + return Result.Success(attachment) + } + // Call on Dispatchers.IO because it accesses file system + val result = withContext(DispatcherProvider.IO) { + mediaRecorder.stopRecording() + } + when (result) { + is Result.Failure -> { + logger.e { "[completeRecordingSync] failed: ${result.value}" } + clearData() + setState(RecordingState.Idle) + return result + } + + is Result.Success -> { + logger.d { "[completeRecordingSync] complete from state: $state" } + val adjusted = samples.downsampleMax(samplesTarget) + val normalized = adjusted.normalize() + clearData() + val attachment = result.value.attachment + val attachmentWithWaveform = attachment.copy( + extraData = attachment.extraData + mapOf(EXTRA_WAVEFORM_DATA to normalized), + ) + setState(RecordingState.Idle) + return Result.Success(attachmentWithWaveform) + } + } + } + + suspend fun completeRecording() { val state = this.recordingState.value logger.w { "[completeRecording] state: $state" } if (state is RecordingState.Idle) { @@ -339,19 +392,22 @@ internal class AudioRecordingController( logger.d { "[completeRecording] completing from Overview state" } audioPlayer.resetAudio(state.playingId) clearData() - setState( - RecordingState.Complete( - state.attachment.copy( - extraData = state.attachment.extraData + mapOf( - EXTRA_WAVEFORM_DATA to state.waveform, - ), - ), + val complete = RecordingState.Complete( + state.attachment.copy( + extraData = state.attachment.extraData + mapOf(EXTRA_WAVEFORM_DATA to state.waveform), ), ) - setState(RecordingState.Idle) + // Run on Dispatchers.Main to ensure both state updates are published in order (complete and idle) + withContext(DispatcherProvider.Main) { + setState(complete) + setState(RecordingState.Idle) + } return } - val result = mediaRecorder.stopRecording() + // Run on Dispatchers.IO because it accesses file system + val result = withContext(DispatcherProvider.IO) { + mediaRecorder.stopRecording() + } if (result.isFailure) { logger.e { "[completeRecording] failed: ${result.errorOrNull()}" } clearData() @@ -364,18 +420,19 @@ internal class AudioRecordingController( val recorded = result.getOrThrow().let { it.copy( attachment = it.attachment.copy( - extraData = it.attachment.extraData + mapOf( - EXTRA_WAVEFORM_DATA to normalized, - ), + extraData = it.attachment.extraData + mapOf(EXTRA_WAVEFORM_DATA to normalized), ), ) } logger.d { "[completeRecording] complete from state: $state" } - setState(RecordingState.Complete(recorded.attachment)) - setState(RecordingState.Idle) + // Run on Dispatchers.Main to ensure both state updates are published in order (complete and idle) + withContext(DispatcherProvider.Main) { + setState(RecordingState.Complete(recorded.attachment)) + setState(RecordingState.Idle) + } } - public fun onCleared() { + fun onCleared() { logger.i { "[onCleared] no args" } mediaRecorder.release() val state = this.recordingState.value diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index c04aec807c1..8dc8bf42b1a 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -143,7 +143,6 @@ public class MessageComposerController( private val scope = CoroutineScope(DispatcherProvider.Immediate) private val audioRecordingController = AudioRecordingController( - channelCid, chatClient.audioPlayer, mediaRecorder, fileToUri, @@ -886,7 +885,9 @@ public class MessageComposerController( * from [RecordingState.Idle] to [RecordingState.Hold]. */ public fun startRecording(offset: Pair? = null) { - audioRecordingController.startRecording(offset) + scope.launch { + audioRecordingController.startRecording(offset) + } } /** @@ -914,13 +915,21 @@ public class MessageComposerController( /** * Stops audio recording and moves [MessageComposerState.recording] state to [RecordingState.Overview]. */ - public fun stopRecording(): Unit = audioRecordingController.stopRecording() + public fun stopRecording() { + scope.launch { + audioRecordingController.stopRecording() + } + } /** * Completes audio recording and moves [MessageComposerState.recording] state to [RecordingState.Complete]. * Also, it wil update [MessageComposerState.attachments] list. */ - public fun completeRecording(): Unit = audioRecordingController.completeRecording() + public fun completeRecording() { + scope.launch { + audioRecordingController.completeRecording() + } + } /** * Pauses audio recording and sets [RecordingState.Overview.isPlaying] to false. @@ -934,6 +943,18 @@ public class MessageComposerController( */ public fun seekRecordingTo(progress: Float): Unit = audioRecordingController.seekRecordingTo(progress) + /** + * Completes the active audio recording and sends the recorded audio as an attachment. + */ + public fun sendRecording() { + scope.launch { + audioRecordingController.completeRecordingSync().onSuccess { recording -> + val attachments = selectedAttachments.value + recording + sendMessage(buildNewMessage(messageInput.value.text, attachments), callback = {}) + } + } + } + /** * Shows the mention suggestion list popup if necessary. */ diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingControllerTest.kt new file mode 100644 index 00000000000..0254eca1d8e --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingControllerTest.kt @@ -0,0 +1,628 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer + +import io.getstream.chat.android.client.audio.AudioPlayer +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.test.TestCoroutineRule +import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState +import io.getstream.result.Error +import io.getstream.result.Result +import io.getstream.sdk.chat.audio.recording.RecordedMedia +import io.getstream.sdk.chat.audio.recording.StreamMediaRecorder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +internal class AudioRecordingControllerTest { + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + private lateinit var mockAudioPlayer: AudioPlayer + private lateinit var mockMediaRecorder: StreamMediaRecorder + private val mockFile: File = mock() + private val fileToUri: (File) -> String = { file -> "file://${file.absolutePath}" } + private lateinit var controller: AudioRecordingController + + @BeforeEach + fun setUp() { + mockAudioPlayer = mock() + mockMediaRecorder = mock() + controller = AudioRecordingController( + audioPlayer = mockAudioPlayer, + mediaRecorder = mockMediaRecorder, + fileToUri = fileToUri, + scope = testCoroutineRule.scope, + ) + } + + @Test + fun `Given Idle state When startRecording is called Then state transitions to Hold`() = runTest { + // Given + whenever(mockMediaRecorder.startAudioRecording(any(), any(), any())) doReturn Result.Success(mockFile) + + // When + controller.startRecording() + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Hold) + assertEquals(RecordingState.Hold.ZeroOffset, (state as RecordingState.Hold).offset) + verify(mockMediaRecorder).startAudioRecording(any(), any(), eq(true)) + } + + @Test + fun `Given Idle state When startRecording with offset is called Then state transitions to Hold with offset`() = runTest { + // Given + val offset = Pair(10f, 20f) + whenever(mockMediaRecorder.startAudioRecording(any(), any(), any())) doReturn Result.Success(mockFile) + + // When + controller.startRecording(offset) + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Hold) + assertEquals(offset, (state as RecordingState.Hold).offset) + } + + @Test + fun `Given non-Idle state When startRecording is called Then state remains unchanged`() = runTest { + // Given + whenever(mockMediaRecorder.startAudioRecording(any(), any(), any())) doReturn Result.Success(mockFile) + controller.startRecording() + val stateBeforeSecondCall = controller.recordingState.value + + // When + controller.startRecording() + + // Then + assertEquals(stateBeforeSecondCall, controller.recordingState.value) + } + + @Test + fun `Given Hold state When holdRecording with offset is called Then offset is updated`() { + // Given + controller.recordingState.value = RecordingState.Hold() + val newOffset = Pair(15f, 25f) + + // When + controller.holdRecording(newOffset) + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Hold) + assertEquals(newOffset, (state as RecordingState.Hold).offset) + } + + @Test + fun `Given Hold state When holdRecording with null offset is called Then state remains unchanged`() { + // Given + val originalOffset = Pair(10f, 20f) + controller.recordingState.value = RecordingState.Hold(offset = originalOffset) + + // When + controller.holdRecording(null) + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Hold) + assertEquals(originalOffset, (state as RecordingState.Hold).offset) + } + + @Test + fun `Given non-Hold state When holdRecording is called Then state remains unchanged`() { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + controller.holdRecording(Pair(10f, 20f)) + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Hold state When lockRecording is called Then state transitions to Locked`() { + // Given + val duration = 5000 + val waveform = listOf(0.5f, 0.6f, 0.7f) + controller.recordingState.value = RecordingState.Hold(durationInMs = duration, waveform = waveform) + + // When + controller.lockRecording() + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Locked) + assertEquals(duration, (state as RecordingState.Locked).durationInMs) + assertEquals(waveform, state.waveform) + } + + @Test + fun `Given non-Hold state When lockRecording is called Then state remains unchanged`() { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + controller.lockRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Recording state When cancelRecording is called Then state transitions to Idle`() { + // Given + controller.recordingState.value = RecordingState.Hold() + + // When + controller.cancelRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + verify(mockAudioPlayer, never()).resetAudio(any()) + verify(mockMediaRecorder).release() + } + + @Test + fun `Given Overview state with playing audio When cancelRecording is called Then audio is reset and state transitions to Idle`() { + // Given + val attachment = Attachment(upload = mockFile) + val playingId = 12345 + controller.recordingState.value = RecordingState.Overview( + attachment = attachment, + isPlaying = true, + playingId = playingId, + ) + + // When + controller.cancelRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + verify(mockAudioPlayer).resetAudio(playingId) + verify(mockMediaRecorder).release() + } + + @Test + fun `Given Idle state When cancelRecording is called Then state remains Idle`() { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + controller.cancelRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + verify(mockMediaRecorder, never()).release() + } + + @Test + fun `Given Overview state When toggleRecordingPlayback is called first time Then audio starts playing`() { + // Given + val attachment = Attachment(upload = mockFile) + controller.recordingState.value = RecordingState.Overview(attachment = attachment) + + // When + controller.toggleRecordingPlayback() + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Overview) + assertTrue((state as RecordingState.Overview).isPlaying) + verify(mockAudioPlayer).play(any(), any()) + } + + @Test + fun `Given Overview state with playing audio When toggleRecordingPlayback is called Then audio pauses`() { + // Given + val attachment = Attachment(upload = mockFile) + val audioHash = attachment.hashCode() + whenever(mockAudioPlayer.currentPlayingId) doReturn audioHash + controller.recordingState.value = RecordingState.Overview( + attachment = attachment, + isPlaying = true, + playingId = audioHash, + ) + + // When + controller.toggleRecordingPlayback() + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Overview) + assertFalse((state as RecordingState.Overview).isPlaying) + verify(mockAudioPlayer).pause() + } + + @Test + fun `Given Overview state with paused audio When toggleRecordingPlayback is called Then audio resumes`() { + // Given + val attachment = Attachment(upload = mockFile) + val audioHash = attachment.hashCode() + whenever(mockAudioPlayer.currentPlayingId) doReturn audioHash + controller.recordingState.value = RecordingState.Overview( + attachment = attachment, + isPlaying = false, + playingId = audioHash, + ) + + // When + controller.toggleRecordingPlayback() + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Overview) + assertTrue((state as RecordingState.Overview).isPlaying) + verify(mockAudioPlayer).resume(audioHash) + } + + @Test + fun `Given Overview state without audio file When toggleRecordingPlayback is called Then state remains unchanged`() { + // Given + val attachment = Attachment(upload = null) + controller.recordingState.value = RecordingState.Overview(attachment = attachment) + val stateBefore = controller.recordingState.value + + // When + controller.toggleRecordingPlayback() + + // Then + assertEquals(stateBefore, controller.recordingState.value) + } + + @Test + fun `Given non-Overview state When toggleRecordingPlayback is called Then state remains unchanged`() { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + controller.toggleRecordingPlayback() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Locked state When stopRecording succeeds Then state transitions to Overview`() = runTest { + // Given + val duration = 5000 + val attachment = Attachment(upload = mockFile) + val recordedMedia = RecordedMedia(durationInMs = duration, attachment = attachment) + whenever(mockMediaRecorder.stopRecording()) doReturn Result.Success(recordedMedia) + controller.recordingState.value = RecordingState.Locked() + + // When + controller.stopRecording() + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Overview) + assertEquals(duration, (state as RecordingState.Overview).durationInMs) + assertEquals(attachment, state.attachment) + } + + @Test + fun `Given Locked state When stopRecording fails Then state transitions to Idle`() = runTest { + // Given + whenever(mockMediaRecorder.stopRecording()) doReturn Result.Failure(Error.GenericError("Failed to stop")) + controller.recordingState.value = RecordingState.Locked() + + // When + controller.stopRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given non-Locked state When stopRecording is called Then state remains unchanged`() = runTest { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + controller.stopRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Overview state When seekRecordingTo is called Then playback position and progress are updated`() { + // Given + val duration = 10000 + val attachment = Attachment(upload = mockFile) + val audioHash = attachment.hashCode() + controller.recordingState.value = RecordingState.Overview( + durationInMs = duration, + attachment = attachment, + ) + val progress = 0.5f + + // When + controller.seekRecordingTo(progress) + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Overview) + assertEquals(progress, (state as RecordingState.Overview).playingProgress) + verify(mockAudioPlayer).seekTo((progress * duration).toInt(), audioHash) + } + + @Test + fun `Given Overview state without audio file When seekRecordingTo is called Then state remains unchanged`() { + // Given + val attachment = Attachment(upload = null) + controller.recordingState.value = RecordingState.Overview(attachment = attachment) + val stateBefore = controller.recordingState.value + + // When + controller.seekRecordingTo(0.5f) + + // Then + assertEquals(stateBefore, controller.recordingState.value) + } + + @Test + fun `Given non-Overview state When seekRecordingTo is called Then state remains unchanged`() { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + controller.seekRecordingTo(0.5f) + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Overview state When pauseRecording is called Then isPlaying becomes false`() { + // Given + val playingId = 12345 + val attachment = Attachment(upload = mockFile) + controller.recordingState.value = RecordingState.Overview( + attachment = attachment, + isPlaying = true, + playingId = playingId, + ) + + // When + controller.pauseRecording() + + // Then + val state = controller.recordingState.value + assertTrue(state is RecordingState.Overview) + assertFalse((state as RecordingState.Overview).isPlaying) + verify(mockAudioPlayer).startSeek(playingId) + } + + @Test + fun `Given non-Overview state When pauseRecording is called Then state remains unchanged`() { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + controller.pauseRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Idle state When completeRecordingSync is called Then Failure result is returned`() = runTest { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + val result = controller.completeRecordingSync() + + // Then + assertTrue(result is Result.Failure) + assertEquals("Recording is in Idle state", (result as Result.Failure).value.message) + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Overview state When completeRecordingSync is called Then Success result with attachment is returned`() = runTest { + // Given + val attachment = Attachment(upload = mockFile) + val waveform = listOf(0.5f, 0.6f, 0.7f) + controller.recordingState.value = RecordingState.Overview( + attachment = attachment, + waveform = waveform, + ) + + // When + val result = controller.completeRecordingSync() + + // Then + assertTrue(result is Result.Success) + val resultAttachment = (result as Result.Success).value + assertTrue(resultAttachment.extraData.containsKey("waveform_data")) + assertEquals(waveform, resultAttachment.extraData["waveform_data"]) + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Hold state When completeRecordingSync succeeds Then Success result with attachment is returned`() = runTest { + // Given + val attachment = Attachment(upload = mockFile) + val recordedMedia = RecordedMedia(durationInMs = 5000, attachment = attachment) + whenever(mockMediaRecorder.stopRecording()) doReturn Result.Success(recordedMedia) + controller.recordingState.value = RecordingState.Hold() + + // When + val result = controller.completeRecordingSync() + + // Then + assertTrue(result is Result.Success) + val resultAttachment = (result as Result.Success).value + assertTrue(resultAttachment.extraData.containsKey("waveform_data")) + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Hold state When completeRecordingSync fails Then Failure result is returned`() = runTest { + // Given + val error = Error.GenericError("Failed to complete") + whenever(mockMediaRecorder.stopRecording()) doReturn Result.Failure(error) + controller.recordingState.value = RecordingState.Hold() + + // When + val result = controller.completeRecordingSync() + + // Then + assertTrue(result is Result.Failure) + assertEquals(error, (result as Result.Failure).value) + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Locked state When completeRecordingSync succeeds Then Success result with attachment is returned`() = runTest { + // Given + val attachment = Attachment(upload = mockFile) + val recordedMedia = RecordedMedia(durationInMs = 5000, attachment = attachment) + whenever(mockMediaRecorder.stopRecording()) doReturn Result.Success(recordedMedia) + controller.recordingState.value = RecordingState.Locked() + + // When + val result = controller.completeRecordingSync() + + // Then + assertTrue(result is Result.Success) + val resultAttachment = (result as Result.Success).value + assertTrue(resultAttachment.extraData.containsKey("waveform_data")) + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `Given Overview state When completeRecording is called Then state transitions through Complete to Idle`() = runTest { + // Given + Dispatchers.setMain(testCoroutineRule.testDispatcher) + val attachment = Attachment(upload = mockFile) + val playingId = 12345 + controller.recordingState.value = RecordingState.Overview( + attachment = attachment, + playingId = playingId, + ) + + // When + controller.completeRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + verify(mockAudioPlayer).resetAudio(playingId) + + Dispatchers.resetMain() + } + + @Test + fun `Given Hold state When completeRecording succeeds Then state transitions through Complete to Idle`() = runTest { + // Given + Dispatchers.setMain(testCoroutineRule.testDispatcher) + val attachment = Attachment(upload = mockFile) + val recordedMedia = RecordedMedia(durationInMs = 5000, attachment = attachment) + whenever(mockMediaRecorder.stopRecording()) doReturn Result.Success(recordedMedia) + controller.recordingState.value = RecordingState.Hold() + + // When + controller.completeRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + verify(mockMediaRecorder).stopRecording() + + Dispatchers.resetMain() + } + + @Test + fun `Given Hold state When completeRecording fails Then state transitions to Idle`() = runTest { + // Given + whenever(mockMediaRecorder.stopRecording()) doReturn Result.Failure(Error.GenericError("Failed")) + controller.recordingState.value = RecordingState.Hold() + + // When + controller.completeRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + verify(mockMediaRecorder).stopRecording() + } + + @Test + fun `Given Idle state When completeRecording is called Then state remains Idle`() = runTest { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + controller.completeRecording() + + // Then + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `When onCleared is called Then resources are released and state becomes Idle`() { + // Given + val attachment = Attachment(upload = mockFile) + val playingId = 12345 + controller.recordingState.value = RecordingState.Overview( + attachment = attachment, + playingId = playingId, + ) + + // When + controller.onCleared() + + // Then + verify(mockMediaRecorder).release() + verify(mockAudioPlayer).resetAudio(playingId) + assertTrue(controller.recordingState.value is RecordingState.Idle) + } + + @Test + fun `When onCleared is called with Idle state Then only mediaRecorder is released`() { + // Given + controller.recordingState.value = RecordingState.Idle + + // When + controller.onCleared() + + // Then + verify(mockMediaRecorder).release() + assertTrue(controller.recordingState.value is RecordingState.Idle) + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt index dec5e37ba21..174d1704cf9 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt @@ -256,10 +256,7 @@ public class MessageComposerViewModel( public fun seekRecordingTo(progress: Float): Unit = messageComposerController.seekRecordingTo(progress) - public fun sendRecording() { - completeRecording() - sendMessage(buildNewMessage()) - } + public fun sendRecording(): Unit = messageComposerController.sendRecording() /** * Disposes the inner [MessageComposerController].