Merge pull request #5821 from element-hq/feature/bma/fixLongVoiceRecording

Fix long voice recording
This commit is contained in:
Benoit Marty 2025-12-03 10:27:46 +01:00 committed by GitHub
commit 2a73ce0f62
10 changed files with 169 additions and 188 deletions

View file

@ -12,17 +12,17 @@ import androidx.lifecycle.Lifecycle
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
sealed interface VoiceMessageComposerEvents { sealed interface VoiceMessageComposerEvent {
data class RecorderEvent( data class RecorderEvent(
val recorderEvent: VoiceMessageRecorderEvent val recorderEvent: VoiceMessageRecorderEvent
) : VoiceMessageComposerEvents ) : VoiceMessageComposerEvent
data class PlayerEvent( data class PlayerEvent(
val playerEvent: VoiceMessagePlayerEvent, val playerEvent: VoiceMessagePlayerEvent,
) : VoiceMessageComposerEvents ) : VoiceMessageComposerEvent
data object SendVoiceMessage : VoiceMessageComposerEvents data object SendVoiceMessage : VoiceMessageComposerEvent
data object DeleteVoiceMessage : VoiceMessageComposerEvents data object DeleteVoiceMessage : VoiceMessageComposerEvent
data object AcceptPermissionRationale : VoiceMessageComposerEvents data object AcceptPermissionRationale : VoiceMessageComposerEvent
data object DismissPermissionsRationale : VoiceMessageComposerEvents data object DismissPermissionsRationale : VoiceMessageComposerEvent
data class LifecycleEvent(val event: Lifecycle.Event) : VoiceMessageComposerEvents data class LifecycleEvent(val event: Lifecycle.Event) : VoiceMessageComposerEvent
data object DismissSendFailureDialog : VoiceMessageComposerEvents data object DismissSendFailureDialog : VoiceMessageComposerEvent
} }

View file

@ -17,5 +17,5 @@ data class VoiceMessageComposerState(
val showPermissionRationaleDialog: Boolean, val showPermissionRationaleDialog: Boolean,
val showSendFailureDialog: Boolean, val showSendFailureDialog: Boolean,
val keepScreenOn: Boolean, val keepScreenOn: Boolean,
val eventSink: (VoiceMessageComposerEvents) -> Unit, val eventSink: (VoiceMessageComposerEvent) -> Unit,
) )

View file

@ -44,7 +44,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -120,7 +120,7 @@ fun MessagesView(
knockRequestsBannerView: @Composable () -> Unit, knockRequestsBannerView: @Composable () -> Unit,
) { ) {
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event)) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
} }
KeepScreenOn(state.voiceMessageComposerState.keepScreenOn) KeepScreenOn(state.voiceMessageComposerState.keepScreenOn)
@ -399,17 +399,17 @@ private fun MessagesViewContent(
if (state.voiceMessageComposerState.showPermissionRationaleDialog) { if (state.voiceMessageComposerState.showPermissionRationaleDialog) {
VoiceMessagePermissionRationaleDialog( VoiceMessagePermissionRationaleDialog(
onContinue = { onContinue = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale)
}, },
onDismiss = { onDismiss = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale)
}, },
appName = state.appName appName = state.appName
) )
} }
if (state.voiceMessageComposerState.showSendFailureDialog) { if (state.voiceMessageComposerState.showSendFailureDialog) {
VoiceMessageSendingFailedDialog( VoiceMessageSendingFailedDialog(
onDismiss = { state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissSendFailureDialog) }, onDismiss = { state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissSendFailureDialog) },
) )
} }

View file

@ -18,7 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
@ -78,19 +78,19 @@ internal fun MessageComposerView(
} }
val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent -> val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecorderEvent(press)) voiceMessageState.eventSink(VoiceMessageComposerEvent.RecorderEvent(press))
} }
val onSendVoiceMessage = { val onSendVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
} }
val onDeleteVoiceMessage = { val onDeleteVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) voiceMessageState.eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage)
} }
val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent -> val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.PlayerEvent(event)) voiceMessageState.eventSink(VoiceMessageComposerEvent.PlayerEvent(event))
} }
TextComposer( TextComposer(

View file

@ -26,7 +26,7 @@ import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
@ -164,25 +164,25 @@ class DefaultVoiceMessageComposerPresenter(
} }
} }
fun handleEvent(event: VoiceMessageComposerEvents) { fun handleEvent(event: VoiceMessageComposerEvent) {
when (event) { when (event) {
is VoiceMessageComposerEvents.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent) is VoiceMessageComposerEvent.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent)
is VoiceMessageComposerEvents.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent) is VoiceMessageComposerEvent.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent)
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch { is VoiceMessageComposerEvent.SendVoiceMessage -> localCoroutineScope.launch {
sendVoiceMessage() sendVoiceMessage()
} }
VoiceMessageComposerEvents.DeleteVoiceMessage -> { VoiceMessageComposerEvent.DeleteVoiceMessage -> {
player.pause() player.pause()
localCoroutineScope.deleteRecording() localCoroutineScope.deleteRecording()
} }
VoiceMessageComposerEvents.DismissPermissionsRationale -> { VoiceMessageComposerEvent.DismissPermissionsRationale -> {
permissionState.eventSink(PermissionsEvents.CloseDialog) permissionState.eventSink(PermissionsEvents.CloseDialog)
} }
VoiceMessageComposerEvents.AcceptPermissionRationale -> { VoiceMessageComposerEvent.AcceptPermissionRationale -> {
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog) permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
} }
is VoiceMessageComposerEvents.LifecycleEvent -> handleLifecycleEvent(event.event) is VoiceMessageComposerEvent.LifecycleEvent -> handleLifecycleEvent(event.event)
VoiceMessageComposerEvents.DismissSendFailureDialog -> { VoiceMessageComposerEvent.DismissSendFailureDialog -> {
showSendFailureDialog = false showSendFailureDialog = false
} }
} }
@ -192,7 +192,10 @@ class DefaultVoiceMessageComposerPresenter(
voiceMessageState = when (val state = recorderState) { voiceMessageState = when (val state = recorderState) {
is VoiceRecorderState.Recording -> VoiceMessageState.Recording( is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
duration = state.elapsedTime, duration = state.elapsedTime,
levels = state.levels.toImmutableList(), levels = state.levels
// Keep only the last 128 samples for display, else we can have a crash
.takeLast(128)
.toImmutableList(),
) )
is VoiceRecorderState.Finished -> is VoiceRecorderState.Finished ->
previewState( previewState(

View file

@ -12,13 +12,10 @@ package io.element.android.features.messages.impl.voicemessages.composer
import android.Manifest import android.Manifest
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aReplyMode import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
@ -42,10 +39,12 @@ import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageException import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -57,7 +56,7 @@ import java.io.File
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class VoiceMessageComposerPresenterTest { class DefaultVoiceMessageComposerPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -91,9 +90,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
voiceRecorder.assertCalls(started = 0) voiceRecorder.assertCalls(started = 0)
@ -105,10 +102,8 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - recording state`() = runTest { fun `present - recording state`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
@ -118,20 +113,42 @@ class VoiceMessageComposerPresenterTest {
} }
} }
@Test
fun `present - recording state - number of levels is limited`() = runTest {
val numberOfLevels = 200
val levels = List(numberOfLevels) { it / numberOfLevels.toFloat() }
val voiceRecorder = FakeVoiceRecorder(
levels = levels,
recordingDuration = RECORDING_DURATION,
)
val presenter = createDefaultVoiceMessageComposerPresenter(
voiceRecorder = voiceRecorder,
)
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
skipItems(numberOfLevels / 2 - 1)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isInstanceOf(VoiceMessageState.Recording::class.java)
val recordingState = finalState.voiceMessageState as VoiceMessageState.Recording
// The number of levels should be limited to 128 items
assertThat(recordingState.levels.size).isEqualTo(128)
assertThat(recordingState.levels).isEqualTo(levels.takeLast(128))
testPauseAndDestroy(finalState)
}
}
@Test @Test
fun `present - recording keeps screen on`() = runTest { fun `present - recording keeps screen on`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
awaitItem().apply { awaitItem().apply {
eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
assertThat(keepScreenOn).isFalse() assertThat(keepScreenOn).isFalse()
} }
awaitItem().apply { awaitItem().apply {
assertThat(keepScreenOn).isTrue() assertThat(keepScreenOn).isTrue()
eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop)) eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
} }
val finalState = awaitItem().apply { val finalState = awaitItem().apply {
@ -145,11 +162,9 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - abort recording`() = runTest { fun `present - abort recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Cancel))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Cancel))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
@ -160,11 +175,9 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - finish recording`() = runTest { fun `present - finish recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
@ -177,12 +190,10 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - play recording before it is ready`() = runTest { fun `present - play recording before it is ready`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
val finalState = awaitItem().apply { val finalState = awaitItem().apply {
this.eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) this.eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
} }
// Nothing should happen // Nothing should happen
@ -196,12 +207,10 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - play recording`() = runTest { fun `present - play recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
val finalState = awaitItem().also { val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPlayingState()) assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
} }
@ -214,13 +223,11 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - pause recording`() = runTest { fun `present - pause recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Pause))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
val finalState = awaitItem().also { val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPausedState()) assertThat(it.voiceMessageState).isEqualTo(aPausedState())
} }
@ -233,18 +240,16 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - seek recording`() = runTest { fun `present - seek recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true)) assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true))
} }
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true)) assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true))
eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f))) eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
} }
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true)) assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true))
@ -260,12 +265,10 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - delete recording`() = runTest { fun `present - delete recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage)
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@ -278,13 +281,11 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - delete while playing`() = runTest { fun `present - delete while playing`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage)
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPausedState()) assertThat(voiceMessageState).isEqualTo(aPausedState())
} }
@ -300,12 +301,10 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send recording`() = runTest { fun `present - send recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@ -319,21 +318,19 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - sending is tracked`() = runTest { fun `present - sending is tracked`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Send a normal voice message // Send a normal voice message
messageComposerContext.composerMode = MessageComposerMode.Normal messageComposerContext.composerMode = MessageComposerMode.Normal
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
skipItems(1) // Sending state skipItems(1) // Sending state
advanceUntilIdle() advanceUntilIdle()
// Now reply with a voice message // Now reply with a voice message
messageComposerContext.composerMode = aReplyMode() messageComposerContext.composerMode = aReplyMode()
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
val finalState = awaitItem() // Sending state val finalState = awaitItem() // Sending state
assertThat(analyticsService.capturedEvents).containsExactly( assertThat(analyticsService.capturedEvents).containsExactly(
@ -348,13 +345,11 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send while playing`() = runTest { fun `present - send while playing`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState()) assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
skipItems(1) // Duplicate sending state skipItems(1) // Duplicate sending state
@ -370,14 +365,12 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send recording before previous completed, waits`() = runTest { fun `present - send recording before previous completed, waits`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().run { awaitItem().run {
eventSink(VoiceMessageComposerEvents.SendVoiceMessage) eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
eventSink(VoiceMessageComposerEvents.SendVoiceMessage) eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
} }
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
@ -395,14 +388,12 @@ class VoiceMessageComposerPresenterTest {
// Let sending fail due to media preprocessing error // Let sending fail due to media preprocessing error
mediaPreProcessor.givenResult(Result.failure(Exception())) mediaPreProcessor.givenResult(Result.failure(Exception()))
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState()) assertThat(voiceMessageState).isEqualTo(aPreviewState())
eventSink(VoiceMessageComposerEvents.SendVoiceMessage) eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
} }
val finalState = awaitItem() val finalState = awaitItem()
@ -419,15 +410,13 @@ class VoiceMessageComposerPresenterTest {
fun `present - send failures can be retried`() = runTest { fun `present - send failures can be retried`() = runTest {
// Let sending fail due to media preprocessing error // Let sending fail due to media preprocessing error
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
mediaPreProcessor.givenResult(Result.failure(Exception())) mediaPreProcessor.givenResult(Result.failure(Exception()))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
val previewState = awaitItem() val previewState = awaitItem()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) previewState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
ensureAllEventsConsumed() ensureAllEventsConsumed()
@ -435,7 +424,7 @@ class VoiceMessageComposerPresenterTest {
sendVoiceMessageResult.assertions().isNeverCalled() sendVoiceMessageResult.assertions().isNeverCalled()
mediaPreProcessor.givenAudioResult() mediaPreProcessor.givenAudioResult()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) previewState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendVoiceMessageResult.assertions().isCalledOnce() sendVoiceMessageResult.assertions().isCalledOnce()
@ -448,14 +437,12 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send failures are displayed as an error dialog`() = runTest { fun `present - send failures are displayed as an error dialog`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Let sending fail due to media preprocessing error // Let sending fail due to media preprocessing error
mediaPreProcessor.givenResult(Result.failure(Exception())) mediaPreProcessor.givenResult(Result.failure(Exception()))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
@ -467,7 +454,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState()) assertThat(voiceMessageState).isEqualTo(aPreviewState())
assertThat(showSendFailureDialog).isTrue() assertThat(showSendFailureDialog).isTrue()
eventSink(VoiceMessageComposerEvents.DismissSendFailureDialog) eventSink(VoiceMessageComposerEvent.DismissSendFailureDialog)
} }
val finalState = awaitItem().apply { val finalState = awaitItem().apply {
@ -483,12 +470,10 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send error - missing recording is tracked`() = runTest { fun `present - send error - missing recording is tracked`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
// Send the message before recording anything // Send the message before recording anything
initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) initialState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendVoiceMessageResult.assertions().isNeverCalled() sendVoiceMessageResult.assertions().isNeverCalled()
@ -504,11 +489,9 @@ class VoiceMessageComposerPresenterTest {
val exception = SecurityException("") val exception = SecurityException("")
voiceRecorder.givenThrowsSecurityException(exception) voiceRecorder.givenThrowsSecurityException(exception)
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
sendVoiceMessageResult.assertions().isNeverCalled() sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).containsExactly( assertThat(analyticsService.trackedErrors).containsExactly(
@ -528,19 +511,17 @@ class VoiceMessageComposerPresenterTest {
val presenter = createDefaultVoiceMessageComposerPresenter( val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter, permissionsPresenter = permissionsPresenter,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop)) initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
voiceRecorder.assertCalls(stopped = 1) voiceRecorder.assertCalls(stopped = 1)
permissionsPresenter.setPermissionGranted() permissionsPresenter.setPermissionGranted()
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
voiceRecorder.assertCalls(stopped = 1, started = 1) voiceRecorder.assertCalls(stopped = 1, started = 1)
@ -557,16 +538,14 @@ class VoiceMessageComposerPresenterTest {
val presenter = createDefaultVoiceMessageComposerPresenter( val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter, permissionsPresenter = permissionsPresenter,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
// See the dialog and accept it // See the dialog and accept it
awaitItem().also { awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(it.showPermissionRationaleDialog).isTrue() assertThat(it.showPermissionRationaleDialog).isTrue()
it.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale) it.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale)
} }
// Dialog is hidden, user accepts permissions // Dialog is hidden, user accepts permissions
@ -574,7 +553,7 @@ class VoiceMessageComposerPresenterTest {
permissionsPresenter.setPermissionGranted() permissionsPresenter.setPermissionGranted()
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
voiceRecorder.assertCalls(started = 1) voiceRecorder.assertCalls(started = 1)
@ -591,22 +570,20 @@ class VoiceMessageComposerPresenterTest {
val presenter = createDefaultVoiceMessageComposerPresenter( val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter, permissionsPresenter = permissionsPresenter,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
// See the dialog and accept it // See the dialog and accept it
awaitItem().also { awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(it.showPermissionRationaleDialog).isTrue() assertThat(it.showPermissionRationaleDialog).isTrue()
it.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale) it.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale)
} }
// Dialog is hidden, user tries to record again // Dialog is hidden, user tries to record again
awaitItem().also { awaitItem().also {
assertThat(it.showPermissionRationaleDialog).isFalse() assertThat(it.showPermissionRationaleDialog).isFalse()
it.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) it.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
} }
// Dialog is shown once again // Dialog is shown once again
@ -624,7 +601,7 @@ class VoiceMessageComposerPresenterTest {
mostRecentState: VoiceMessageComposerState, mostRecentState: VoiceMessageComposerState,
) { ) {
mostRecentState.eventSink( mostRecentState.eventSink(
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE) VoiceMessageComposerEvent.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE)
) )
val onPauseState = when (val state = mostRecentState.voiceMessageState) { val onPauseState = when (val state = mostRecentState.voiceMessageState) {
@ -645,7 +622,7 @@ class VoiceMessageComposerPresenterTest {
} }
onPauseState.eventSink( onPauseState.eventSink(
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY) VoiceMessageComposerEvent.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY)
) )
when (val state = onPauseState.voiceMessageState) { when (val state = onPauseState.voiceMessageState) {
@ -662,6 +639,7 @@ class VoiceMessageComposerPresenterTest {
private fun TestScope.createDefaultVoiceMessageComposerPresenter( private fun TestScope.createDefaultVoiceMessageComposerPresenter(
permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(),
voiceRecorder: VoiceRecorder = this@DefaultVoiceMessageComposerPresenterTest.voiceRecorder,
): DefaultVoiceMessageComposerPresenter { ): DefaultVoiceMessageComposerPresenter {
return DefaultVoiceMessageComposerPresenter( return DefaultVoiceMessageComposerPresenter(
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,

View file

@ -299,7 +299,10 @@ fun TextComposer(
onSeek = onSeekVoiceMessage, onSeek = onSeekVoiceMessage,
) )
is VoiceMessageState.Recording -> is VoiceMessageState.Recording ->
VoiceMessageRecording(voiceMessageState.levels, voiceMessageState.duration) VoiceMessageRecording(
levels = voiceMessageState.levels,
duration = voiceMessageState.duration,
)
VoiceMessageState.Idle -> {} VoiceMessageState.Idle -> {}
} }
} }

View file

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -51,27 +50,23 @@ fun LiveWaveformView(
linePadding: Dp = 2.dp, linePadding: Dp = 2.dp,
) { ) {
var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) } var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) }
var parentWidth by remember { mutableIntStateOf(0) } var parentWidth by remember { mutableIntStateOf(0) }
val waveformWidth = remember(levels.size, lineWidth, linePadding) {
val waveformWidth by remember(levels, lineWidth, linePadding) { levels.size * (lineWidth.value + linePadding.value)
derivedStateOf {
levels.size * (lineWidth.value + linePadding.value)
}
} }
Box( Box(
contentAlignment = Alignment.CenterEnd, contentAlignment = Alignment.CenterEnd,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(waveFormHeight) .height(waveFormHeight)
.onSizeChanged { parentWidth = it.width } .onSizeChanged { parentWidth = it.width }
) { ) {
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.width(Dp(waveformWidth)) .width(Dp(waveformWidth))
.graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA) .graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA)
.then(modifier) .then(modifier)
) { ) {
val width = min(waveformWidth, parentWidth.toFloat()) val width = min(waveformWidth, parentWidth.toFloat())
canvasSize = DpSize(width.dp, size.height.toDp()) canvasSize = DpSize(width.dp, size.height.toDp())

View file

@ -74,7 +74,7 @@ internal fun VoiceMessageRecording(
modifier = Modifier modifier = Modifier
.height(26.dp) .height(26.dp)
.weight(1f), .weight(1f),
levels = levels levels = levels,
) )
} }
} }

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.yield
import java.io.File import java.io.File
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -49,6 +50,7 @@ class FakeVoiceRecorder(
timeSource += recordingDuration timeSource += recordingDuration
for (i in 1..levels.size) { for (i in 1..levels.size) {
_state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), levels.take(i))) _state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), levels.take(i)))
yield()
} }
} }