From 9d2b37f07b4dbfe9b38298623fcfb999bce363a0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Aug 2024 12:01:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20generate=20effects=20file=20on=20demand?= =?UTF-8?q?=20[WPB-9713]=20=F0=9F=8D=92=20(#3299)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jakub Żerko --- .../recordaudio/AudioMediaRecorder.kt | 13 +-- .../recordaudio/RecordAudioViewModel.kt | 98 +++++++++++++++---- .../recordaudio/RecordAudioViewModelTest.kt | 95 ++++++++++++++++-- 3 files changed, 166 insertions(+), 40 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt index ec0d9ed2750..22fba813bcb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt @@ -66,7 +66,6 @@ class AudioMediaRecorder @Inject constructor( private var assetLimitInMB: Long = ASSET_SIZE_DEFAULT_LIMIT_BYTES var originalOutputPath: Path? = null - var effectsOutputPath: Path? = null var mp4OutputPath: Path? = null private val _maxFileSizeReached = MutableSharedFlow() @@ -94,9 +93,6 @@ class AudioMediaRecorder @Inject constructor( originalOutputPath = kaliumFileSystem .tempFilePath(getRecordingAudioFileName()) - effectsOutputPath = kaliumFileSystem - .tempFilePath(getRecordingAudioEffectsFileName()) - mp4OutputPath = kaliumFileSystem .tempFilePath(getRecordingMP4AudioFileName()) } @@ -223,18 +219,12 @@ class AudioMediaRecorder @Inject constructor( } @Suppress("LongMethod", "CyclomaticComplexMethod") - suspend fun convertWavToMp4(shouldApplyEffects: Boolean): Boolean = withContext(Dispatchers.IO) { + suspend fun convertWavToMp4(inputFilePath: String): Boolean = withContext(Dispatchers.IO) { var codec: MediaCodec? = null var muxer: MediaMuxer? = null var fileInputStream: FileInputStream? = null try { - val inputFilePath = if (shouldApplyEffects) { - effectsOutputPath!!.toString() - } else { - originalOutputPath!!.toString() - } - val inputFile = File(inputFilePath) fileInputStream = FileInputStream(inputFile) @@ -339,7 +329,6 @@ class AudioMediaRecorder @Inject constructor( companion object { fun getRecordingAudioFileName(): String = "wire-audio-${DateTimeUtil.currentInstant().fileDateTime()}.wav" fun getRecordingMP4AudioFileName(): String = "wire-audio-${DateTimeUtil.currentInstant().fileDateTime()}.mp4" - fun getRecordingAudioEffectsFileName(): String = "wire-audio-${DateTimeUtil.currentInstant().fileDateTime()}-filter.wav" const val SIZE_OF_1MB = 1024 * 1024 const val AUDIO_CHANNELS = 1 // Mono diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt index ea5c2d05a87..bcc95331587 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt @@ -34,10 +34,13 @@ import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.android.util.SUPPORTED_AUDIO_MIME_TYPE import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.fileDateTime import com.wire.android.util.getAudioLengthInMs import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.asset.KaliumFileSystem import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase +import com.wire.kalium.util.DateTimeUtil import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow @@ -50,7 +53,7 @@ import java.io.IOException import javax.inject.Inject import kotlin.io.path.deleteIfExists -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class RecordAudioViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -61,7 +64,8 @@ class RecordAudioViewModel @Inject constructor( private val currentScreenManager: CurrentScreenManager, private val audioMediaRecorder: AudioMediaRecorder, private val globalDataStore: GlobalDataStore, - private val dispatchers: DispatcherProvider + private val dispatchers: DispatcherProvider, + private val kaliumFileSystem: KaliumFileSystem ) : ViewModel() { var state: RecordAudioState by mutableStateOf(RecordAudioState()) @@ -156,11 +160,16 @@ class RecordAudioViewModel @Inject constructor( } else { viewModelScope.launch(dispatchers.default()) { val assetSizeLimit = getAssetSizeLimit(false) + if (state.shouldApplyEffects && state.effectsOutputFile == null) { + state = state.copy( + effectsOutputFile = kaliumFileSystem + .tempFilePath(getRecordingAudioEffectsFileName()).toFile() + ) + } audioMediaRecorder.setUp(assetSizeLimit) if (audioMediaRecorder.startRecording()) { state = state.copy( originalOutputFile = audioMediaRecorder.originalOutputPath!!.toFile(), - effectsOutputFile = audioMediaRecorder.effectsOutputPath!!.toFile(), buttonState = RecordAudioButtonState.RECORDING ) } else { @@ -179,16 +188,18 @@ class RecordAudioViewModel @Inject constructor( appLogger.i("[$tag] -> Releasing audioMediaRecorder") audioMediaRecorder.release() - if (state.originalOutputFile != null && state.effectsOutputFile != null) { + if (state.originalOutputFile != null) { state = state.copy( buttonState = RecordAudioButtonState.ENCODING, audioState = state.audioState.copy(audioMediaPlayingState = AudioMediaPlayingState.Fetching) ) - generateAudioFileWithEffects( - context = context, - originalFilePath = state.originalOutputFile!!.path, - effectsFilePath = state.effectsOutputFile!!.path - ) + if (state.shouldApplyEffects && state.effectsOutputFile != null) { + generateAudioFileWithEffects( + context = context, + originalFilePath = state.originalOutputFile!!.path, + effectsFilePath = state.effectsOutputFile!!.path + ) + } state = state.copy( buttonState = RecordAudioButtonState.READY_TO_SEND, @@ -267,27 +278,34 @@ class RecordAudioViewModel @Inject constructor( viewModelScope.launch { recordAudioMessagePlayer.stop() recordAudioMessagePlayer.close() + + val outputFile = state.originalOutputFile + val effectsFile = state.effectsOutputFile state = state.copy( buttonState = RecordAudioButtonState.ENCODING, audioState = AudioState.DEFAULT, originalOutputFile = null, effectsOutputFile = null ) - val didSucceed = audioMediaRecorder.convertWavToMp4(state.shouldApplyEffects) + val didSucceed = if (state.shouldApplyEffects) { + audioMediaRecorder.convertWavToMp4(effectsFile!!.toString()) + } else { + audioMediaRecorder.convertWavToMp4(outputFile!!.toString()) + } try { when { didSucceed -> { - state.originalOutputFile?.toPath()?.deleteIfExists() - state.effectsOutputFile?.toPath()?.deleteIfExists() + outputFile?.toPath()?.deleteIfExists() + effectsFile?.toPath()?.deleteIfExists() } state.shouldApplyEffects -> { - state.originalOutputFile?.toPath()?.deleteIfExists() + outputFile?.toPath()?.deleteIfExists() } !state.shouldApplyEffects -> { - state.effectsOutputFile?.toPath()?.deleteIfExists() + effectsFile?.toPath()?.deleteIfExists() } } } catch (exception: IOException) { @@ -300,9 +318,9 @@ class RecordAudioViewModel @Inject constructor( audioMediaRecorder.mp4OutputPath!!.toFile().toUri() } else { if (state.shouldApplyEffects) { - audioMediaRecorder.effectsOutputPath!!.toFile().toUri() + state.effectsOutputFile!!.toUri() } else { - audioMediaRecorder.originalOutputPath!!.toFile().toUri() + state.originalOutputFile!!.toUri() } }, mimeType = if (didSucceed) { @@ -338,14 +356,56 @@ class RecordAudioViewModel @Inject constructor( fun setShouldApplyEffects(enabled: Boolean) { viewModelScope.launch { globalDataStore.setRecordAudioEffectsCheckboxEnabled(enabled) + if (enabled && state.effectsOutputFile == null) { + val effectsFile = kaliumFileSystem + .tempFilePath(getRecordingAudioEffectsFileName()).toFile() + if (state.buttonState == RecordAudioButtonState.READY_TO_SEND) { + state = state.copy( + buttonState = RecordAudioButtonState.ENCODING, + audioState = state.audioState.copy(audioMediaPlayingState = AudioMediaPlayingState.Fetching) + ) + + generateAudioFileWithEffects( + context = context, + originalFilePath = state.originalOutputFile!!.path, + effectsFilePath = effectsFile.path + ) + + state = state.copy( + effectsOutputFile = effectsFile, + buttonState = RecordAudioButtonState.READY_TO_SEND, + audioState = AudioState( + audioMediaPlayingState = AudioMediaPlayingState.Stopped, + currentPositionInMs = 0, + AudioState.TotalTimeInMs.Known( + getAudioLengthInMs( + dataPath = effectsFile.path.toPath(), + mimeType = SUPPORTED_AUDIO_MIME_TYPE + ).toInt() + ) + ), + shouldApplyEffects = true + ) + } else { + state = state.copy( + effectsOutputFile = effectsFile, + shouldApplyEffects = true + ) + } + } else { + state = state.copy( + shouldApplyEffects = enabled + ) + } } - state = state.copy( - shouldApplyEffects = enabled - ) } override fun onCleared() { super.onCleared() recordAudioMessagePlayer.close() } + + companion object { + fun getRecordingAudioEffectsFileName(): String = "wire-audio-${DateTimeUtil.currentInstant().fileDateTime()}-filter.wav" + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt index 59d7c44460a..a420175b209 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt @@ -57,6 +57,7 @@ class RecordAudioViewModelTest { // given val (_, viewModel) = Arrangement() .withEstablishedCall() + .withFilterEnabled(false) .arrange() viewModel.getInfoMessage().test { @@ -77,6 +78,7 @@ class RecordAudioViewModelTest { runTest { // given val (arrangement, viewModel) = Arrangement() + .withFilterEnabled(false) .arrange() // when @@ -94,10 +96,11 @@ class RecordAudioViewModelTest { } @Test - fun `given user is recording audio, when stopping the recording, then send audio button is shown`() = + fun `given user is recording audio without filter, when stopping the recording, then send audio button is shown`() = runTest { // given val (arrangement, viewModel) = Arrangement() + .withFilterEnabled(false) .arrange() viewModel.startRecording() @@ -105,14 +108,78 @@ class RecordAudioViewModelTest { // when viewModel.stopRecording() + // then + coVerify(exactly = 0) { + arrangement.generateAudioFileWithEffects( + context = any(), + originalFilePath = any(), + effectsFilePath = any() + ) + } + assertEquals( + RecordAudioButtonState.READY_TO_SEND, + viewModel.state.buttonState + ) + } + + @Test + fun `given user is recording audio with filter, when stopping the recording, then send audio button is shown`() = + runTest { + // given + val (arrangement, viewModel) = Arrangement() + .withFilterEnabled(true) + .arrange() + + viewModel.startRecording() + + // when + viewModel.stopRecording() + + // then + coVerify(exactly = 1) { + arrangement.generateAudioFileWithEffects( + context = any(), + originalFilePath = any(), + effectsFilePath = any(), + ) + } + assertEquals( + RecordAudioButtonState.READY_TO_SEND, + viewModel.state.buttonState + ) + } + + @Test + fun `given user is recording audio without filter, when applying filter after recording, then effects file is generated`() = + runTest { + // given + val (arrangement, viewModel) = Arrangement() + .withFilterEnabled(false) + .arrange() + + viewModel.startRecording() + viewModel.stopRecording() + assertEquals(null, viewModel.state.effectsOutputFile) + coVerify(exactly = 0) { + arrangement.generateAudioFileWithEffects( + context = any(), + originalFilePath = any(), + effectsFilePath = any() + ) + } + + // when + viewModel.setShouldApplyEffects(true) + // then coVerify(exactly = 1) { arrangement.generateAudioFileWithEffects( context = any(), - originalFilePath = viewModel.state.originalOutputFile!!.path, - effectsFilePath = viewModel.state.effectsOutputFile!!.path + originalFilePath = any(), + effectsFilePath = any() ) } + assert(viewModel.state.effectsOutputFile != null) assertEquals( RecordAudioButtonState.READY_TO_SEND, viewModel.state.buttonState @@ -124,6 +191,7 @@ class RecordAudioViewModelTest { runTest { // given val (_, viewModel) = Arrangement() + .withFilterEnabled(false) .arrange() // when @@ -141,6 +209,7 @@ class RecordAudioViewModelTest { runTest { // given val (_, viewModel) = Arrangement() + .withFilterEnabled(false) .arrange() viewModel.startRecording() @@ -160,6 +229,7 @@ class RecordAudioViewModelTest { runTest { // given val (_, viewModel) = Arrangement() + .withFilterEnabled(false) .arrange() // when @@ -177,6 +247,7 @@ class RecordAudioViewModelTest { runTest { // given val (_, viewModel) = Arrangement() + .withFilterEnabled(false) .arrange() // when @@ -194,6 +265,7 @@ class RecordAudioViewModelTest { runTest { // given val (_, viewModel) = Arrangement() + .withFilterEnabled(false) .arrange() // when @@ -211,6 +283,7 @@ class RecordAudioViewModelTest { runTest { // given val (_, viewModel) = Arrangement() + .withFilterEnabled(false) .arrange() viewModel.startRecording() @@ -240,6 +313,7 @@ class RecordAudioViewModelTest { // given val (_, viewModel) = Arrangement() .withStartRecordingSuccessful() + .withFilterEnabled(false) .arrange() viewModel.getInfoMessage().test { @@ -257,6 +331,7 @@ class RecordAudioViewModelTest { // given val (_, viewModel) = Arrangement() .withStartRecordingFailed() + .withFilterEnabled(false) .arrange() viewModel.getInfoMessage().test { @@ -279,6 +354,7 @@ class RecordAudioViewModelTest { val generateAudioFileWithEffects = mockk() val context = mockk() val dispatchers = TestDispatcherProvider() + val fakeKaliumFileSystem = FakeKaliumFileSystem() val viewModel by lazy { RecordAudioViewModel( @@ -290,25 +366,22 @@ class RecordAudioViewModelTest { getAssetSizeLimit = getAssetSizeLimit, generateAudioFileWithEffects = generateAudioFileWithEffects, globalDataStore = globalDataStore, - dispatchers = dispatchers + dispatchers = dispatchers, + kaliumFileSystem = fakeKaliumFileSystem ) } init { MockKAnnotations.init(this, relaxUnitFun = true) - val fakeKaliumFileSystem = FakeKaliumFileSystem() - coEvery { getAssetSizeLimit.invoke(false) } returns ASSET_SIZE_LIMIT every { audioMediaRecorder.setUp(ASSET_SIZE_LIMIT) } returns Unit every { audioMediaRecorder.startRecording() } returns true every { audioMediaRecorder.stop() } returns Unit every { audioMediaRecorder.release() } returns Unit - every { globalDataStore.isRecordAudioEffectsCheckboxEnabled() } returns flowOf(false) + coEvery { globalDataStore.setRecordAudioEffectsCheckboxEnabled(any()) } returns Unit every { audioMediaRecorder.originalOutputPath } returns fakeKaliumFileSystem .tempFilePath("temp_recording.wav") - every { audioMediaRecorder.effectsOutputPath } returns fakeKaliumFileSystem - .tempFilePath("temp_recording_effects.wav") coEvery { audioMediaRecorder.getMaxFileSizeReached() } returns flowOf( RecordAudioDialogState.MaxFileSizeReached( maxSize = GetAssetSizeLimitUseCaseImpl.ASSET_SIZE_DEFAULT_LIMIT_BYTES @@ -342,6 +415,10 @@ class RecordAudioViewModelTest { fun withStartRecordingSuccessful() = apply { every { audioMediaRecorder.startRecording() } returns true } fun withStartRecordingFailed() = apply { every { audioMediaRecorder.startRecording() } returns false } + fun withFilterEnabled(isEnabled: Boolean) = apply { + every { globalDataStore.isRecordAudioEffectsCheckboxEnabled() } returns flowOf(isEnabled) + } + fun arrange() = this to viewModel companion object {