Merge branch 'develop' into feature/bma/limitComposerHeight

This commit is contained in:
Benoit Marty 2025-12-03 17:44:05 +01:00
commit 35e75b944f
230 changed files with 2910 additions and 1855 deletions

View file

@ -49,7 +49,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
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.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -126,7 +126,7 @@ fun MessagesView(
knockRequestsBannerView: @Composable () -> Unit,
) {
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
}
KeepScreenOn(state.voiceMessageComposerState.keepScreenOn)
@ -413,17 +413,17 @@ private fun MessagesViewContent(
if (state.voiceMessageComposerState.showPermissionRationaleDialog) {
VoiceMessagePermissionRationaleDialog(
onContinue = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale)
},
onDismiss = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale)
},
appName = state.appName
)
}
if (state.voiceMessageComposerState.showSendFailureDialog) {
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.tooling.preview.PreviewParameter
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.VoiceMessageComposerStateProvider
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
@ -78,19 +78,19 @@ internal fun MessageComposerView(
}
val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecorderEvent(press))
voiceMessageState.eventSink(VoiceMessageComposerEvent.RecorderEvent(press))
}
val onSendVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
}
val onDeleteVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
voiceMessageState.eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage)
}
val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.PlayerEvent(event))
voiceMessageState.eventSink(VoiceMessageComposerEvent.PlayerEvent(event))
}
TextComposer(

View file

@ -65,6 +65,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -261,6 +262,7 @@ class TimelinePresenter(
items
}
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
.flowOn(dispatchers.computation)
.launchIn(this)
}

View file

@ -26,7 +26,7 @@ import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.Composer
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.VoiceMessageComposerState
import io.element.android.libraries.di.RoomScope
@ -164,25 +164,25 @@ class DefaultVoiceMessageComposerPresenter(
}
}
fun handleEvent(event: VoiceMessageComposerEvents) {
fun handleEvent(event: VoiceMessageComposerEvent) {
when (event) {
is VoiceMessageComposerEvents.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent)
is VoiceMessageComposerEvents.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent)
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch {
is VoiceMessageComposerEvent.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent)
is VoiceMessageComposerEvent.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent)
is VoiceMessageComposerEvent.SendVoiceMessage -> localCoroutineScope.launch {
sendVoiceMessage()
}
VoiceMessageComposerEvents.DeleteVoiceMessage -> {
VoiceMessageComposerEvent.DeleteVoiceMessage -> {
player.pause()
localCoroutineScope.deleteRecording()
}
VoiceMessageComposerEvents.DismissPermissionsRationale -> {
VoiceMessageComposerEvent.DismissPermissionsRationale -> {
permissionState.eventSink(PermissionsEvents.CloseDialog)
}
VoiceMessageComposerEvents.AcceptPermissionRationale -> {
VoiceMessageComposerEvent.AcceptPermissionRationale -> {
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
}
is VoiceMessageComposerEvents.LifecycleEvent -> handleLifecycleEvent(event.event)
VoiceMessageComposerEvents.DismissSendFailureDialog -> {
is VoiceMessageComposerEvent.LifecycleEvent -> handleLifecycleEvent(event.event)
VoiceMessageComposerEvent.DismissSendFailureDialog -> {
showSendFailureDialog = false
}
}
@ -192,7 +192,10 @@ class DefaultVoiceMessageComposerPresenter(
voiceMessageState = when (val state = recorderState) {
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
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 ->
previewState(

View file

@ -42,6 +42,10 @@
<string name="screen_room_timeline_reactions_show_more">"نمایش بیش‌تر"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"نمایش خلاصهٔ واکنش‌ها"</string>
<string name="screen_room_timeline_read_marker_title">"جدید"</string>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$dتغییر اتاق"</item>
<item quantity="other">"%1$dتغییر اتاق"</item>
</plurals>
<string name="screen_room_timeline_tombstoned_room_action">"پرش به اتاق جدید"</string>
<string name="screen_room_timeline_tombstoned_room_message">"این اتاق جایگزین شده و دیگر فعّال نیست"</string>
<string name="screen_room_timeline_upgraded_room_action">"دیدن پیام‌های قدیمی"</string>

View file

@ -7,6 +7,7 @@
<string name="emoji_picker_category_objects">"Oggetti"</string>
<string name="emoji_picker_category_people">"Faccine &amp; Persone"</string>
<string name="emoji_picker_category_places">"Viaggi &amp; Luoghi"</string>
<string name="emoji_picker_category_recent">"Emoji recenti"</string>
<string name="emoji_picker_category_symbols">"Simboli"</string>
<string name="screen_media_upload_preview_caption_warning">"Le didascalie potrebbero non essere visibili agli utenti di app meno recenti."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tocca per modificare la qualità di caricamento del video"</string>
@ -15,6 +16,7 @@
<string name="screen_media_upload_preview_error_failed_sending">"Caricamento del file multimediale fallito, riprova."</string>
<string name="screen_media_upload_preview_error_too_large_message">"La dimensione massima consentita del file è %1$s ."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Il file è troppo grande per essere caricato"</string>
<string name="screen_media_upload_preview_item_count">"Elemento %1$d di %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Ottimizza la qualità delle immagini"</string>
<string name="screen_media_upload_preview_processing">"Elaborazione…"</string>
<string name="screen_report_content_block_user">"Blocca utente"</string>

View file

@ -7,13 +7,18 @@
<string name="emoji_picker_category_objects">"Objetos"</string>
<string name="emoji_picker_category_people">"Sorrisos &amp; Pessoas"</string>
<string name="emoji_picker_category_places">"Viagens &amp; Lugares"</string>
<string name="emoji_picker_category_recent">"Emojis recentes"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</string>
<string name="screen_media_upload_preview_caption_warning">"As legendas podem não ser visíveis para pessoas que usam apps mais antigos."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Toque para alterar a qualidade do envio do vídeo"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"O arquivo não pôde ser enviado."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Falha ao processar a mídia para o envio. Tente novamente."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Falha ao enviar mídia. Tente novamente."</string>
<string name="screen_media_upload_preview_error_too_large_message">"O tamanho de arquivo máximo permitido é %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"O arquivo é muito grande para enviar"</string>
<string name="screen_media_upload_preview_item_count">"%1$d de %2$d itens"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Otimizar qualidade da imagem"</string>
<string name="screen_media_upload_preview_processing">"Processando…"</string>
<string name="screen_report_content_block_user">"Bloquear usuário"</string>
<string name="screen_report_content_block_user_hint">"Marque se você deseja ocultar todas as mensagens atuais e futuras desse usuário"</string>
<string name="screen_report_content_explanation">"Essa mensagem será reportada ao administrador do seu servidor-casa. Eles não conseguirão ler nenhuma mensagem criptografada."</string>

View file

@ -8,8 +8,15 @@
<string name="emoji_picker_category_people">"Smayllar va odamlar"</string>
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
<string name="emoji_picker_category_symbols">"Belgilar"</string>
<string name="screen_media_upload_preview_caption_warning">"Taglavhalar eski ilovalardan foydalanuvchilarga korinmasligi mumkin."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Video yuklash sifatini oʻzgartirish uchun bosing"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Faylni yuklab boʻlmadi."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Mediani yuklab bolmadi, qayta urinib koring."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Media yuklanmadi, qayta urinib koring."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Ruxsat etilgan maksimal fayl hajmi %1$s ."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Fayl yuklash uchun juda katta"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Tasvir sifatini optimallashtirish"</string>
<string name="screen_media_upload_preview_processing">"Qayta ishlanmoqda…"</string>
<string name="screen_report_content_block_user">"Foydalanuvchini bloklash"</string>
<string name="screen_report_content_block_user_hint">"Ushbu foydalanuvchidan barcha joriy va kelajakdagi xabarlarni yashirishni xohlayotganingizni tekshiring"</string>
<string name="screen_report_content_explanation">"Bu xabar uy serveringiz administratoriga xabar qilinadi. Ular hech qanday shifrlangan xabarlarni o\'qiy olmaydi."</string>
@ -33,16 +40,31 @@
<string name="screen_room_timeline_add_reaction">"Emoji qo\'shmoq"</string>
<string name="screen_room_timeline_beginning_of_room">"Bu %1$sni boshlanishi"</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Bu suhbatning boshlanishi."</string>
<string name="screen_room_timeline_legacy_call">"Chaqiruv qabul qilinmaydi. Chaqiruvchidan yangi Element X ilovasidan foydalanishi mumkinligini sorang."</string>
<string name="screen_room_timeline_less_reactions">"Kamroq ko\'rsatish"</string>
<string name="screen_room_timeline_message_copied">"Xabar nusxalandi"</string>
<string name="screen_room_timeline_no_permission_to_post">"Sizda bu xonaga post yozishga ruxsat yoq"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d ta azo %2$s bilan munosabat bildirdi"</item>
<item quantity="other">"%1$d ta azo %2$s bilan munosabat bildirdi"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"Siz va %1$d ta azo %2$s bilan munosabat bildirdi"</item>
<item quantity="other">"Siz va %1$d ta azo %2$s bilan munosabat bildirdi"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"%1$s bilan munosabat bildirdingiz"</string>
<string name="screen_room_timeline_reactions_show_less">"Kamroq ko\'rsatish"</string>
<string name="screen_room_timeline_reactions_show_more">"Ko\'proq ko\'rsatish"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Reaksiyalar xulosasini chiqarish"</string>
<string name="screen_room_timeline_read_marker_title">"Yangi"</string>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$dxonani almashtirish"</item>
<item quantity="other">"%1$dxona o\'zgarishi"</item>
</plurals>
<string name="screen_room_timeline_tombstoned_room_action">"Yangi xonaga otish"</string>
<string name="screen_room_timeline_tombstoned_room_message">"Bu room almashtirildi va endi faol emas"</string>
<string name="screen_room_timeline_upgraded_room_action">"Eski xabarlarni korish"</string>
<string name="screen_room_timeline_upgraded_room_message">"Bu xona boshqa xonaning davomi"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s va %3$d boshqalar"</item>
<item quantity="other">"%1$s, %2$s va %3$d boshqalar"</item>

View file

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