From 88a706fac0beeda40e31215ebdd35cfde49b6c98 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Fri, 29 Nov 2024 11:48:18 +0200 Subject: [PATCH 01/19] feat: Audio messages new design --- .../android/media/audiomessage/AudioState.kt | 3 +- .../ConversationAudioMessagePlayer.kt | 2 +- .../messagetypes/audio/AudioMessageType.kt | 44 ++++++++++++------- app/src/main/res/values/strings.xml | 2 + kalium | 2 +- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt index 7fa9d4a98ea..92cced15c9c 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt @@ -20,7 +20,8 @@ package com.wire.android.media.audiomessage data class AudioState( val audioMediaPlayingState: AudioMediaPlayingState, val currentPositionInMs: Int, - val totalTimeInMs: TotalTimeInMs + val totalTimeInMs: TotalTimeInMs, +// val speed: Float cyka ) { companion object { val DEFAULT = AudioState(AudioMediaPlayingState.Stopped, 0, TotalTimeInMs.NotKnown) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index 3f6c08ed1be..abbd0f5ef15 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -76,7 +76,7 @@ internal constructor( @KaliumCoreLogic private val coreLogic: CoreLogic, ) { private companion object { - const val UPDATE_POSITION_INTERVAL_IN_MS = 1000L + const val UPDATE_POSITION_INTERVAL_IN_MS = 200L } init { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt index a95d1fb5eb9..acc8a257567 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt @@ -150,13 +150,14 @@ private fun SuccessfulAudioMessage( .height(dimensions().audioMessageHeight), verticalAlignment = Alignment.CenterVertically ) { + val (iconResource, contentDescriptionRes) = getPlayOrPauseIcon(audioMediaPlayingState) WireSecondaryIconButton( - minSize = dimensions().buttonSmallMinSize, + minSize = DpSize(dimensions().spacing32x, dimensions().spacing32x), minClickableSize = dimensions().buttonMinClickableSize, iconSize = dimensions().spacing12x, - iconResource = getPlayOrPauseIcon(audioMediaPlayingState), + iconResource = iconResource, shape = CircleShape, - contentDescription = R.string.content_description_image_message, + contentDescription = contentDescriptionRes, state = if (audioMediaPlayingState is AudioMediaPlayingState.Fetching) WireButtonState.Disabled else WireButtonState.Default, onButtonClicked = onPlayButtonClick ) @@ -205,7 +206,7 @@ private fun RowScope.AudioMessageSlider( thumb = { SliderDefaults.Thumb( interactionSource = remember { MutableInteractionSource() }, - thumbSize = DpSize(dimensions().spacing20x, dimensions().spacing20x) + thumbSize = DpSize(dimensions().spacing4x, dimensions().spacing32x) ) }, track = { sliderState -> @@ -268,11 +269,11 @@ private fun FailedAudioMessage() { } } -private fun getPlayOrPauseIcon(audioMediaPlayingState: AudioMediaPlayingState): Int = +private fun getPlayOrPauseIcon(audioMediaPlayingState: AudioMediaPlayingState): Pair = when (audioMediaPlayingState) { - AudioMediaPlayingState.Playing -> R.drawable.ic_pause - AudioMediaPlayingState.Completed -> R.drawable.ic_play - else -> R.drawable.ic_play + AudioMediaPlayingState.Playing -> R.drawable.ic_pause to R.string.content_description_pause_audio + AudioMediaPlayingState.Completed -> R.drawable.ic_play to R.string.content_description_play_audio + else -> R.drawable.ic_play to R.string.content_description_play_audio } // helper wrapper class to format the time that is left @@ -296,17 +297,30 @@ private data class AudioDuration(val totalDurationInMs: AudioState.TotalTimeInMs totalTimeInSec - currentPositionInSec } - // sanity check, timeLeft, should not be smaller, however if the back-end makes mistake we - // will display a negative values, which we do not want - val minutes = if (timeLeft < 0) 0 else timeLeft / totalSecInMin - val seconds = if (timeLeft < 0) 0 else timeLeft % totalSecInMin - val formattedSeconds = String.format("%02d", seconds) - - return "$minutes:$formattedSeconds" + return formattedTime(timeLeft) } return UNKNOWN_DURATION_LABEL } + + fun formattedCurrentTime(): String = + formattedTime(currentPositionInMs / totalMsInSec) + + fun formattedTotalTime(): String = if (totalDurationInMs is AudioState.TotalTimeInMs.Known) { + formattedTime(totalDurationInMs.value / totalMsInSec) + } else { + UNKNOWN_DURATION_LABEL + } + + private fun formattedTime(timeSeconds: Int): String { + // sanity check, timeLeft, should not be smaller, however if the back-end makes mistake we + // will display a negative values, which we do not want + val minutes = if (timeSeconds < 0) 0 else timeSeconds / totalSecInMin + val seconds = if (timeSeconds < 0) 0 else timeSeconds % totalSecInMin + val formattedSeconds = String.format("%02d", seconds) + + return "$minutes:$formattedSeconds" + } } @PreviewMultipleThemes diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36c6d043c92..c4941e028ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,8 @@ More options Add contact Image message + Pause audio + Play audio File message Ping Set timer for self-deleting messages diff --git a/kalium b/kalium index b7b4bd21471..bee4d00171b 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit b7b4bd21471dd3ac48f5f3b3b245cc56723e529f +Subproject commit bee4d00171b79c0a2506c4a2b6eb2f45a250c52c From 3ffc762cf679b3a9a281e690aa193d5638779620 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Fri, 29 Nov 2024 22:43:17 +0200 Subject: [PATCH 02/19] Work in progress --- .../ConversationAudioMessagePlayer.kt | 2 +- .../messagetypes/audio/AudioMessageType.kt | 59 ++++++++++++------- .../wire/android/ui/theme/WireDimensions.kt | 2 +- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index abbd0f5ef15..91cfb97372f 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -76,7 +76,7 @@ internal constructor( @KaliumCoreLogic private val coreLogic: CoreLogic, ) { private companion object { - const val UPDATE_POSITION_INTERVAL_IN_MS = 200L + const val UPDATE_POSITION_INTERVAL_IN_MS = 100L } init { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt index acc8a257567..cba47c5cc50 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt @@ -22,8 +22,9 @@ import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -145,10 +146,7 @@ private fun SuccessfulAudioMessage( } Row( - modifier = modifier - .fillMaxWidth() - .height(dimensions().audioMessageHeight), - verticalAlignment = Alignment.CenterVertically + modifier = modifier.fillMaxWidth(), ) { val (iconResource, contentDescriptionRes) = getPlayOrPauseIcon(audioMediaPlayingState) WireSecondaryIconButton( @@ -162,23 +160,39 @@ private fun SuccessfulAudioMessage( onButtonClicked = onPlayButtonClick ) - AudioMessageSlider( - audioDuration = audioDuration, - totalTimeInMs = totalTimeInMs, - onSliderPositionChange = onSliderPositionChange - ) + Column( + modifier = Modifier.fillMaxWidth(), + ) { - if (audioMediaPlayingState is AudioMediaPlayingState.Fetching) { - WireCircularProgressIndicator( - progressColor = MaterialTheme.wireColorScheme.secondaryButtonEnabled - ) - } else { - Text( - text = audioDuration.formattedTimeLeft(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.wireColorScheme.secondaryText, - maxLines = 1 + AudioMessageSlider( + audioDuration = audioDuration, + totalTimeInMs = totalTimeInMs, + onSliderPositionChange = onSliderPositionChange ) + + Row { + Text( + text = audioDuration.formattedCurrentTime(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.wireColorScheme.primary, + maxLines = 1 + ) + + Spacer(Modifier.weight(1F)) + + if (audioMediaPlayingState is AudioMediaPlayingState.Fetching) { + WireCircularProgressIndicator( + progressColor = MaterialTheme.wireColorScheme.secondaryButtonEnabled + ) + } else { + Text( + text = audioDuration.formattedTotalTime(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.wireColorScheme.secondaryText, + maxLines = 1 + ) + } + } } } } @@ -194,11 +208,12 @@ private fun SuccessfulAudioMessage( */ @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun RowScope.AudioMessageSlider( +private fun AudioMessageSlider( audioDuration: AudioDuration, totalTimeInMs: AudioState.TotalTimeInMs, onSliderPositionChange: (Float) -> Unit, ) { + // cyka check this for waves https://stackoverflow.com/questions/38744579/show-waveform-of-audio Slider( value = audioDuration.currentPositionInMs.toFloat(), onValueChange = onSliderPositionChange, @@ -222,7 +237,7 @@ private fun RowScope.AudioMessageSlider( colors = SliderDefaults.colors( inactiveTrackColor = colorsScheme().secondaryButtonDisabledOutline ), - modifier = Modifier.weight(1f) + modifier = Modifier.fillMaxWidth() ) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index 324e9477336..81fc570b382 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -356,7 +356,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( messageItemHorizontalPadding = 12.dp, conversationOptionsItemMinHeight = 57.dp, ongoingCallLabelHeight = 28.dp, - audioMessageHeight = 48.dp, + audioMessageHeight = 68.dp, importedMediaAssetSize = 120.dp, typingIndicatorHeight = 24.dp, legalHoldBannerMinHeight = 26.dp, From 0e0f03e75ca52b5919034a9d089c5721cd8a4d43 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Mon, 9 Dec 2024 12:15:58 +0200 Subject: [PATCH 03/19] Audio messages new design almost ready --- app/build.gradle.kts | 1 + .../kotlin/com/wire/android/di/AppModule.kt | 4 + .../android/media/audiomessage/AudioState.kt | 38 ++++- .../audiomessage/AudioWavesMaskHelper.kt | 81 ++++++++++ .../ConversationAudioMessagePlayer.kt | 63 +++++++- .../audiomessage/RecordAudioMessagePlayer.kt | 17 ++- .../home/conversations/ConversationScreen.kt | 18 ++- .../media/ConversationMediaScreen.kt | 3 +- .../conversations/media/FileAssetsContent.kt | 19 ++- .../messages/ConversationMessagesViewModel.kt | 39 ++++- .../messages/ConversationMessagesViewState.kt | 10 +- .../messages/item/MessageClickActions.kt | 3 + .../messages/item/MessageContainerItem.kt | 3 + .../messages/item/MessageContentAndStatus.kt | 10 ++ .../messages/item/RegularMessageItem.kt | 7 +- .../messagetypes/audio/AudioMessageType.kt | 139 ++++++++++++++---- .../recordaudio/RecordAudioButtons.kt | 6 +- .../recordaudio/RecordAudioViewModel.kt | 12 +- app/src/main/res/values/strings.xml | 3 + .../ConversationAudioMessagePlayerTest.kt | 9 ++ ...onversationMessagesViewModelArrangement.kt | 4 + .../recordaudio/RecordAudioViewModelTest.kt | 8 + gradle/libs.versions.toml | 3 + 23 files changed, 438 insertions(+), 62 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 52065cb0462..77fc097d467 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -259,6 +259,7 @@ dependencies { implementation(libs.aboutLibraries.core) implementation(libs.aboutLibraries.ui) implementation(libs.compose.qr.code) + implementation(libs.audio.amplituda) // screenshot testing screenshotTestImplementation(libs.compose.ui.tooling) diff --git a/app/src/main/kotlin/com/wire/android/di/AppModule.kt b/app/src/main/kotlin/com/wire/android/di/AppModule.kt index 82aed26f4ee..bbf8efa2c40 100644 --- a/app/src/main/kotlin/com/wire/android/di/AppModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/AppModule.kt @@ -38,6 +38,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import linc.com.amplituda.Amplituda import javax.inject.Qualifier import javax.inject.Singleton @@ -84,6 +85,9 @@ object AppModule { } } + @Provides + fun provideAmplituda(appContext: Context): Amplituda = Amplituda(appContext) + @Singleton @Provides fun provideCurrentTimestampProvider(): CurrentTimestampProvider = { System.currentTimeMillis() } diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt index 92cced15c9c..aa5ef18ef02 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt @@ -17,21 +17,24 @@ */ package com.wire.android.media.audiomessage +import androidx.annotation.StringRes +import com.wire.android.R + data class AudioState( val audioMediaPlayingState: AudioMediaPlayingState, val currentPositionInMs: Int, val totalTimeInMs: TotalTimeInMs, -// val speed: Float cyka + val wavesMask: List ) { companion object { - val DEFAULT = AudioState(AudioMediaPlayingState.Stopped, 0, TotalTimeInMs.NotKnown) + val DEFAULT = AudioState(AudioMediaPlayingState.Stopped, 0, TotalTimeInMs.NotKnown, listOf()) } // if the back-end returned the total time, we use that, in case it didn't we use what we get from // the [ConversationAudioMessagePlayer.kt] which will emit the time once the users play the audio. fun sanitizeTotalTime(otherClientTotalTime: Int): TotalTimeInMs { if (otherClientTotalTime != 0) { - return TotalTimeInMs.Known(otherClientTotalTime) + return TotalTimeInMs.Known(otherClientTotalTime) } return totalTimeInMs @@ -44,6 +47,26 @@ data class AudioState( } } +enum class AudioSpeed(val value: Float, @StringRes val titleRes: Int) { + NORMAL(1f, R.string.audio_speed_1), + FAST(1.5f, R.string.audio_speed_1_5), + MAX(2f, R.string.audio_speed_2); + + fun toggle(): AudioSpeed = when (this) { + NORMAL -> FAST + FAST -> MAX + MAX -> NORMAL + } + + companion object { + fun fromFloat(speed: Float): AudioSpeed = when { + (speed > 1.6) -> MAX + (speed > 1) -> FAST + else -> NORMAL + } + } +} + sealed class AudioMediaPlayingState { object Playing : AudioMediaPlayingState() object Stopped : AudioMediaPlayingState() @@ -76,6 +99,11 @@ sealed class AudioMediaPlayerStateUpdate( override val messageId: String, val totalTimeInMs: Int ) : AudioMediaPlayerStateUpdate(messageId) + + data class WaveMaskUpdate( + override val messageId: String, + val waveMask: List + ) : AudioMediaPlayerStateUpdate(messageId) } sealed class RecordAudioMediaPlayerStateUpdate { @@ -90,4 +118,8 @@ sealed class RecordAudioMediaPlayerStateUpdate { data class TotalTimeUpdate( val totalTimeInMs: Int ) : RecordAudioMediaPlayerStateUpdate() + + data class WaveMaskUpdate( + val waveMask: List + ) : RecordAudioMediaPlayerStateUpdate() } diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt new file mode 100644 index 00000000000..39198db3739 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt @@ -0,0 +1,81 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.media.audiomessage + +import linc.com.amplituda.Amplituda +import linc.com.amplituda.Cache +import okio.Path +import java.io.File +import javax.inject.Inject +import kotlin.math.roundToInt + +class AudioWavesMaskHelper @Inject constructor( + private val amplituda: Amplituda +) { + + companion object { + private const val WAVES_AMOUNT = 75 + private const val WAVE_MAX = 32 + } + + fun getWaveMask(decodedAssetPath: Path): List = getWaveMask(File(decodedAssetPath.toString())) + + fun getWaveMask(file: File): List = amplituda + .processAudio(file, Cache.withParams(Cache.REUSE)) + .get() + .amplitudesAsList() + .averageWavesMask() + .equalizeWavesMask() + + private fun List.equalizeWavesMask(): List { + if (this.isEmpty()) return listOf() + + val divider = max() / (WAVE_MAX - 1) + return map { (it / divider).roundToInt() + 1 } + } + + private fun List.averageWavesMask(): List { + val wavesSize = size + val sectionSize = (wavesSize.toFloat() / 75).roundToInt() + + if (wavesSize < WAVES_AMOUNT || sectionSize == 1) return map { it.toDouble() } + + val averagedWaves = mutableListOf() + for (i in 0..<(wavesSize / sectionSize)) { + val startIndex = (i * sectionSize) + if (startIndex >= wavesSize) continue + val endIndex = (startIndex + sectionSize).coerceAtMost(wavesSize - 1) + averagedWaves.add(subList(startIndex, endIndex).averageInt()) + } + return averagedWaves + } + + private fun List.averageInt(): Double { + var sum = 0.0 + var count = 0 + for (element in this) { + sum += element + ++count + } + return if (count == 0) 0.0 else sum / count + } + + fun clear() { + amplituda.clearCache() + } +} diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index 91cfb97372f..cc4a3d10945 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -46,6 +46,7 @@ class ConversationAudioMessagePlayerProvider @Inject constructor( private val context: Context, private val audioMediaPlayer: MediaPlayer, + private val wavesMaskHelper: AudioWavesMaskHelper, @KaliumCoreLogic private val coreLogic: CoreLogic, ) { private var player: ConversationAudioMessagePlayer? = null @@ -53,7 +54,7 @@ class ConversationAudioMessagePlayerProvider @Synchronized fun provide(): ConversationAudioMessagePlayer { - val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, coreLogic).also { player = it } + val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, wavesMaskHelper, coreLogic).also { player = it } usageCount++ return player @@ -73,6 +74,7 @@ class ConversationAudioMessagePlayer internal constructor( private val context: Context, private val audioMediaPlayer: MediaPlayer, + private val wavesMaskHelper: AudioWavesMaskHelper, @KaliumCoreLogic private val coreLogic: CoreLogic, ) { private companion object { @@ -99,6 +101,12 @@ internal constructor( extraBufferCapacity = 1 ) + private val audioSpeedFlow = MutableSharedFlow( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + extraBufferCapacity = 1, + replay = 1 + ) + // MediaPlayer API does not have any mechanism that would inform as about the currentPosition, // in a callback manner, therefore we need to create a timer manually that ticks every 1 second // and emits the current position @@ -166,11 +174,22 @@ internal constructor( ) } } + + is AudioMediaPlayerStateUpdate.WaveMaskUpdate -> { + audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { + put( + audioMessageStateUpdate.messageId, + currentState.copy(wavesMask = audioMessageStateUpdate.waveMask) + ) + } + } } audioMessageStateHistory }.onStart { emit(audioMessageStateHistory) } + val audioSpeed: Flow = audioSpeedFlow.onStart { emit(AudioSpeed.NORMAL) } + private var currentAudioMessageId: String? = null suspend fun playAudio( @@ -190,6 +209,12 @@ internal constructor( } } + suspend fun setSpeed(speed: AudioSpeed) { + val currentParams = audioMediaPlayer.playbackParams + audioMediaPlayer.playbackParams = currentParams.setSpeed(speed.value) + updateSpeedFlow() + } + private fun previouslyResumedPosition(requestedAudioMessageId: String): Int? { return audioMessageStateHistory[requestedAudioMessageId]?.run { if (audioMediaPlayingState == AudioMediaPlayingState.Completed) { @@ -259,10 +284,19 @@ internal constructor( ) audioMediaPlayer.prepare() + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.WaveMaskUpdate( + messageId, + wavesMaskHelper.getWaveMask(result.decodedAssetPath) + ) + ) + if (position != null) audioMediaPlayer.seekTo(position) audioMediaPlayer.start() + updateSpeedFlow() + audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( messageId, @@ -306,8 +340,29 @@ internal constructor( seekToAudioPosition.emit(messageId to position) } + suspend fun fetchWavesMask(conversationId: ConversationId, messageId: String) { + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Failure) return + + val result = coreLogic + .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) + .messages + .getAssetMessage(conversationId, messageId) + .await() + + if (result is MessageAssetResult.Success) { + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.WaveMaskUpdate( + messageId, + wavesMaskHelper.getWaveMask(result.decodedAssetPath) + ) + ) + } + } + private suspend fun resumeAudio(messageId: String) { audioMediaPlayer.start() + updateSpeedFlow() audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Playing) @@ -330,7 +385,13 @@ internal constructor( ) } + private suspend fun updateSpeedFlow() { + val currentSpeed = AudioSpeed.fromFloat(audioMediaPlayer.playbackParams.speed) + audioSpeedFlow.emit(currentSpeed) + } + internal fun close() { audioMediaPlayer.reset() + wavesMaskHelper.clear() } } diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt index 678f7f8aa80..bfc24b46c4e 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt @@ -36,7 +36,8 @@ import javax.inject.Inject @ViewModelScoped class RecordAudioMessagePlayer @Inject constructor( private val context: Context, - private val audioMediaPlayer: MediaPlayer + private val audioMediaPlayer: MediaPlayer, + private val wavesMaskHelper: AudioWavesMaskHelper ) { private var currentAudioFile: File? = null private var audioState: AudioState = AudioState.DEFAULT @@ -110,6 +111,12 @@ class RecordAudioMessagePlayer @Inject constructor( ) ) } + + is RecordAudioMediaPlayerStateUpdate.WaveMaskUpdate -> { + audioState = audioState.copy( + wavesMask = audioStateUpdate.waveMask + ) + } } audioState @@ -164,6 +171,12 @@ class RecordAudioMessagePlayer @Inject constructor( audioMediaPlayer.seekTo(position) audioMediaPlayer.start() + audioMessageStateUpdate.emit( + RecordAudioMediaPlayerStateUpdate.WaveMaskUpdate( + wavesMaskHelper.getWaveMask(audioFile) + ) + ) + audioMessageStateUpdate.emit( RecordAudioMediaPlayerStateUpdate.RecordAudioMediaPlayingStateUpdate( audioMediaPlayingState = AudioMediaPlayingState.Playing @@ -217,6 +230,6 @@ class RecordAudioMessagePlayer @Inject constructor( } private companion object { - const val UPDATE_POSITION_INTERVAL_IN_MS = 1000L + const val UPDATE_POSITION_INTERVAL_IN_MS = 100L } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 8b6cf4c8afc..12da5ffd305 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -86,7 +86,7 @@ import com.wire.android.feature.sketch.destinations.DrawingCanvasScreenDestinati import com.wire.android.feature.sketch.model.DrawingCanvasNavArgs import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs import com.wire.android.mapper.MessageDateTimeGroup -import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand @@ -134,6 +134,7 @@ import com.wire.android.ui.home.conversations.info.ConversationDetailsData import com.wire.android.ui.home.conversations.info.ConversationInfoViewModel import com.wire.android.ui.home.conversations.info.ConversationInfoViewState import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBackArgs +import com.wire.android.ui.home.conversations.messages.AudioMessagesState import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel @@ -511,6 +512,7 @@ fun ConversationScreen( }, onAudioClick = conversationMessagesViewModel::audioClick, onChangeAudioPosition = conversationMessagesViewModel::changeAudioPosition, + onChangeAudioSpeed = conversationMessagesViewModel::changeAudioSpeed, onResetSessionClick = conversationMessagesViewModel::onResetSession, onUpdateConversationReadDate = messageComposerViewModel::updateConversationReadDate, onDropDownClick = { @@ -796,6 +798,7 @@ private fun ConversationScreen( onDeleteMessage: (String, Boolean) -> Unit, onAudioClick: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, + onChangeAudioSpeed: (AudioSpeed) -> Unit, onAssetItemClicked: (String) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean) -> Unit, onStartCall: () -> Unit, @@ -880,7 +883,7 @@ private fun ConversationScreen( audioMessagesState = conversationMessagesViewState.audioMessagesState, assetStatuses = conversationMessagesViewState.assetStatuses, lastUnreadMessageInstant = conversationMessagesViewState.firstUnreadInstant, - unreadEventCount = conversationMessagesViewState.firstuUnreadEventIndex, + unreadEventCount = conversationMessagesViewState.firstUnreadEventIndex, conversationDetailsData = conversationInfoViewState.conversationDetailsData, selectedMessageId = conversationMessagesViewState.searchedMessageId, messageComposerStateHolder = messageComposerStateHolder, @@ -891,6 +894,7 @@ private fun ConversationScreen( onAssetItemClicked = onAssetItemClicked, onAudioItemClicked = onAudioClick, onChangeAudioPosition = onChangeAudioPosition, + onChangeAudioSpeed = onChangeAudioSpeed, onImageFullScreenMode = onImageFullScreenMode, onReactionClicked = onReactionClick, onResetSessionClicked = onResetSessionClick, @@ -957,7 +961,7 @@ private fun ConversationScreenContent( bottomSheetVisible: Boolean, lastUnreadMessageInstant: Instant?, unreadEventCount: Int, - audioMessagesState: PersistentMap, + audioMessagesState: AudioMessagesState, assetStatuses: PersistentMap, selectedMessageId: String?, messageComposerStateHolder: MessageComposerStateHolder, @@ -968,6 +972,7 @@ private fun ConversationScreenContent( onAssetItemClicked: (String) -> Unit, onAudioItemClicked: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, + onChangeAudioSpeed: (AudioSpeed) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean) -> Unit, onReactionClicked: (String, String) -> Unit, onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit, @@ -1015,6 +1020,7 @@ private fun ConversationScreenContent( onAssetClicked = onAssetItemClicked, onPlayAudioClicked = onAudioItemClicked, onAudioPositionChanged = onChangeAudioPosition, + onAudioSpeedChange = onChangeAudioSpeed, onImageClicked = onImageFullScreenMode, onLinkClicked = onLinkClick, onReplyClicked = onNavigateToReplyOriginalMessage, @@ -1083,7 +1089,7 @@ fun MessageList( lazyPagingMessages: LazyPagingItems, lazyListState: LazyListState, lastUnreadMessageInstant: Instant?, - audioMessagesState: PersistentMap, + audioMessagesState: AudioMessagesState, assetStatuses: PersistentMap, onUpdateConversationReadDate: (String) -> Unit, onSwipedToReply: (UIMessage.Regular) -> Unit, @@ -1197,7 +1203,8 @@ fun MessageList( conversationDetailsData = conversationDetailsData, showAuthor = showAuthor, useSmallBottomPadding = useSmallBottomPadding, - audioState = audioMessagesState[message.header.messageId], + audioState = audioMessagesState.audioStates[message.header.messageId], + audioSpeed = audioMessagesState.audioSpeed, assetStatus = assetStatuses[message.header.messageId]?.transferStatus, clickActions = clickActions, swipableMessageConfiguration = swipableConfiguration, @@ -1424,5 +1431,6 @@ fun PreviewConversationScreen() = WireTheme { onLinkClick = { _ -> }, openDrawingCanvas = {}, onImagesPicked = {}, + onChangeAudioSpeed = {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index 1d4a09ab831..19f8a8e1694 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -70,6 +70,7 @@ import com.wire.android.ui.home.conversations.DownloadedAssetDialog import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog import com.wire.android.ui.home.conversations.edit.assetOptionsMenuItems +import com.wire.android.ui.home.conversations.messages.AudioMessagesState import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions @@ -169,7 +170,7 @@ fun ConversationMediaScreen( @Composable private fun Content( state: ConversationAssetMessagesViewState, - audioMessagesState: PersistentMap = persistentMapOf(), + audioMessagesState: AudioMessagesState = AudioMessagesState(), initialPage: ConversationMediaScreenTabItem = ConversationMediaScreenTabItem.PICTURES, onImageFullScreenMode: (conversationId: ConversationId, messageId: String, isSelfAsset: Boolean) -> Unit = { _, _, _ -> }, onPlayAudioItemClicked: (String) -> Unit = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt index a7559b94a90..d4e76a358e6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt @@ -36,10 +36,12 @@ import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey import com.wire.android.R import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.home.conversations.info.ConversationDetailsData +import com.wire.android.ui.home.conversations.messages.AudioMessagesState import com.wire.android.ui.home.conversations.messages.item.MessageClickActions import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem import com.wire.android.ui.home.conversations.messages.item.SwipableMessageConfiguration @@ -65,7 +67,7 @@ import kotlinx.datetime.Instant fun FileAssetsContent( groupedAssetMessageList: Flow>, assetStatuses: PersistentMap, - audioMessagesState: PersistentMap = persistentMapOf(), + audioMessagesState: AudioMessagesState = AudioMessagesState(), onPlayAudioItemClicked: (messageId: String) -> Unit = {}, onAudioItemPositionChanged: (String, Int) -> Unit = { _, _ -> }, onAssetItemClicked: (messageId: String) -> Unit = {}, @@ -93,7 +95,7 @@ fun FileAssetsContent( @Composable private fun AssetMessagesListContent( groupedAssetMessageList: LazyPagingItems, - audioMessagesState: PersistentMap, + audioMessagesState: AudioMessagesState, assetStatuses: PersistentMap, onPlayAudioItemClicked: (messageId: String) -> Unit, onAudioItemPositionChanged: (String, Int) -> Unit, @@ -137,7 +139,8 @@ private fun AssetMessagesListContent( MessageContainerItem( message = message, conversationDetailsData = ConversationDetailsData.None(null), - audioState = audioMessagesState[message.header.messageId], + audioState = audioMessagesState.audioStates[message.header.messageId], + audioSpeed = audioMessagesState.audioSpeed, assetStatus = assetStatuses[message.header.messageId]?.transferStatus, clickActions = MessageClickActions.Content( onFullMessageLongClicked = remember { { onItemLongClicked(it.header.messageId, it.isMyMessage) } }, @@ -171,7 +174,7 @@ private fun AssetMessagesListContent( @PreviewMultipleThemes @Composable fun PreviewFileAssetsEmptyContent() = WireTheme { - FileAssetsContent(groupedAssetMessageList = emptyFlow(), assetStatuses = persistentMapOf(), audioMessagesState = persistentMapOf()) + FileAssetsContent(groupedAssetMessageList = emptyFlow(), assetStatuses = persistentMapOf()) } @PreviewMultipleThemes @@ -182,7 +185,7 @@ fun PreviewFileAssetsContent() = WireTheme { } @Suppress("MagicNumber") -fun mockAssets(): Triple>, PersistentMap, PersistentMap> { +fun mockAssets(): Triple>, PersistentMap, AudioMessagesState> { val msg1 = mockAssetMessage(assetId = "assset1", messageId = "msg1") val msg2 = mockAssetMessage(assetId = "assset2", messageId = "msg2") val msg3 = mockAssetMessage(assetId = "assset3", messageId = "msg3") @@ -207,8 +210,8 @@ fun mockAssets(): Triple>, PersistentMap + conversationViewState = conversationViewState.copy( + audioMessagesState = AudioMessagesState(audioMessageStates.toPersistentMap(), audioSpeed) + ) + } } } @@ -215,11 +222,12 @@ class ConversationMessagesViewModel @Inject constructor( } val paginatedMessagesFlow = getMessageForConversation(conversationId, lastReadIndex) + .requestAudioWavesMaskIfNeeded() .flowOn(dispatchers.io()) conversationViewState = conversationViewState.copy( messages = paginatedMessagesFlow, - firstuUnreadEventIndex = max(lastReadIndex - 1, 0) + firstUnreadEventIndex = max(lastReadIndex - 1, 0) ) handleSelectedSearchedMessageHighlighting() @@ -397,6 +405,12 @@ class ConversationMessagesViewModel @Inject constructor( } } + fun changeAudioSpeed(audioSpeed: AudioSpeed) { + viewModelScope.launch { + conversationAudioMessagePlayer.setSpeed(audioSpeed) + } + } + fun updateImageOnFullscreenMode(message: UIMessage.Regular?) { lastImageMessageShownOnGallery = message } @@ -435,6 +449,19 @@ class ConversationMessagesViewModel @Inject constructor( } } + // checking all the new messages if it's an AudioMessage and fetch WavesMask for it if so + private fun Flow>.requestAudioWavesMaskIfNeeded(): Flow> = + map { + it.map { message -> + if (message.messageContent is UIMessageContent.AudioAssetMessage) { + viewModelScope.launch { + conversationAudioMessagePlayer.fetchWavesMask(conversationId, message.header.messageId) + } + } + message + } + } + override fun onCleared() { super.onCleared() conversationAudioMessagePlayerProvider.onCleared() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt index 6ce5489cf51..d6cb32bb568 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.conversations.messages import androidx.paging.PagingData +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage @@ -32,13 +33,18 @@ import kotlinx.datetime.Instant data class ConversationMessagesViewState( val messages: Flow> = emptyFlow(), val firstUnreadInstant: Instant? = null, - val firstuUnreadEventIndex: Int = 0, + val firstUnreadEventIndex: Int = 0, val downloadedAssetDialogState: DownloadedAssetDialogVisibilityState = DownloadedAssetDialogVisibilityState.Hidden, - val audioMessagesState: PersistentMap = persistentMapOf(), + val audioMessagesState: AudioMessagesState = AudioMessagesState(), val assetStatuses: PersistentMap = persistentMapOf(), val searchedMessageId: String? = null ) +data class AudioMessagesState( + val audioStates: PersistentMap = persistentMapOf(), + val audioSpeed: AudioSpeed = AudioSpeed.NORMAL +) + sealed class DownloadedAssetDialogVisibilityState { object Hidden : DownloadedAssetDialogVisibilityState() data class Displayed(val assetData: AssetBundle, val messageId: String) : DownloadedAssetDialogVisibilityState() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt index 159d2b01081..912173b093e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.home.conversations.messages.item +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId @@ -29,6 +30,7 @@ sealed class MessageClickActions { open val onAssetClicked: (String) -> Unit = {} open val onPlayAudioClicked: (String) -> Unit = {} open val onAudioPositionChanged: (String, Int) -> Unit = { _, _ -> } + open val onAudioSpeedChange: (AudioSpeed) -> Unit = { _ -> } open val onImageClicked: (UIMessage.Regular, Boolean) -> Unit = { _, _ -> } open val onLinkClicked: (String) -> Unit = {} open val onReplyClicked: (UIMessage.Regular) -> Unit = {} @@ -48,6 +50,7 @@ sealed class MessageClickActions { override val onAssetClicked: (String) -> Unit = {}, override val onPlayAudioClicked: (String) -> Unit = {}, override val onAudioPositionChanged: (String, Int) -> Unit = { _, _ -> }, + override val onAudioSpeedChange: (AudioSpeed) -> Unit = { _ -> }, override val onImageClicked: (UIMessage.Regular, Boolean) -> Unit = { _, _ -> }, override val onLinkClicked: (String) -> Unit = {}, override val onReplyClicked: (UIMessage.Regular) -> Unit = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt index 7960fb27861..4e2e58e7ea7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -52,6 +53,7 @@ fun MessageContainerItem( showAuthor: Boolean = true, useSmallBottomPadding: Boolean = false, audioState: AudioState? = null, + audioSpeed: AudioSpeed = AudioSpeed.NORMAL, assetStatus: AssetTransferStatus? = null, shouldDisplayMessageStatus: Boolean = true, shouldDisplayFooter: Boolean = true, @@ -95,6 +97,7 @@ fun MessageContainerItem( clickActions = clickActions, showAuthor = showAuthor, audioState = audioState, + audioSpeed = audioSpeed, assetStatus = assetStatus, swipableMessageConfiguration = swipableMessageConfiguration, failureInteractionAvailable = failureInteractionAvailable, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index b38cbd0ff91..5040809be01 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.wire.android.R +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.model.Clickable import com.wire.android.ui.common.dimensions @@ -41,10 +42,12 @@ internal fun UIMessage.Regular.MessageContentAndStatus( assetStatus: AssetTransferStatus?, searchQuery: String, audioState: AudioState?, + audioSpeed: AudioSpeed, onAssetClicked: (String) -> Unit, onImageClicked: (UIMessage.Regular, Boolean) -> Unit, onAudioClicked: (String) -> Unit, onAudioPositionChanged: (String, Int) -> Unit, + onAudioSpeedChange: (AudioSpeed) -> Unit, onProfileClicked: (String) -> Unit, onLinkClicked: (String) -> Unit, onReplyClicked: (UIMessage.Regular) -> Unit, @@ -73,11 +76,13 @@ internal fun UIMessage.Regular.MessageContentAndStatus( messageContent = messageContent, searchQuery = searchQuery, audioState = audioState, + audioSpeed = audioSpeed, assetStatus = assetStatus, onAudioClick = onAudioClicked, onChangeAudioPosition = onAudioPositionChanged, onAssetClick = onAssetClickable, onImageClick = onImageClickable, + onAudioSpeedChange = onAudioSpeedChange, onOpenProfile = onProfileClicked, onLinkClick = onLinkClicked, onReplyClick = onReplyClickable, @@ -105,11 +110,13 @@ private fun MessageContent( messageContent: UIMessageContent.Regular?, searchQuery: String, audioState: AudioState?, + audioSpeed: AudioSpeed, assetStatus: AssetTransferStatus?, onAssetClick: Clickable, onImageClick: Clickable, onAudioClick: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, + onAudioSpeedChange: (AudioSpeed) -> Unit, onOpenProfile: (String) -> Unit, onLinkClick: (String) -> Unit, onReplyClick: Clickable, @@ -233,10 +240,13 @@ private fun MessageContent( audioMediaPlayingState = audioMessageState.audioMediaPlayingState, totalTimeInMs = totalTimeInMs, currentPositionInMs = audioMessageState.currentPositionInMs, + audioSpeed = audioSpeed, + waveMask = audioMessageState.wavesMask, onPlayButtonClick = { onAudioClick(message.header.messageId) }, onSliderPositionChange = { position -> onChangeAudioPosition(message.header.messageId, position.toInt()) }, + onAudioSpeedChange = { onAudioSpeedChange(audioSpeed.toggle()) } ) PartialDeliveryInformation(messageContent.deliveryStatus) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index e42be6c5184..e8e4d4d79bb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.wire.android.R +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.common.LegalHoldIndicator import com.wire.android.ui.common.StatusBox @@ -73,6 +74,7 @@ fun RegularMessageItem( message: UIMessage.Regular, conversationDetailsData: ConversationDetailsData, audioState: AudioState?, + audioSpeed: AudioSpeed, modifier: Modifier = Modifier, searchQuery: String = "", showAuthor: Boolean = true, @@ -149,14 +151,15 @@ fun RegularMessageItem( onImageClicked = clickActions.onImageClicked, searchQuery = searchQuery, audioState = audioState, + audioSpeed = audioSpeed, onAudioClicked = clickActions.onPlayAudioClicked, onAudioPositionChanged = clickActions.onAudioPositionChanged, onProfileClicked = clickActions.onProfileClicked, onLinkClicked = clickActions.onLinkClicked, shouldDisplayMessageStatus = shouldDisplayMessageStatus, conversationDetailsData = conversationDetailsData, - onReplyClicked = clickActions.onReplyClicked - + onReplyClicked = clickActions.onReplyClicked, + onAudioSpeedChange = clickActions.onAudioSpeedChange ) if (shouldDisplayFooter) { VerticalSpace.x4() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt index cba47c5cc50..d322f5aaa89 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -51,12 +52,14 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.wire.android.R import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.model.Clickable import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryIconButton import com.wire.android.ui.common.clickable import com.wire.android.ui.common.colorsScheme @@ -65,6 +68,8 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.spacers.HorizontalSpace import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -72,8 +77,11 @@ fun AudioMessage( audioMediaPlayingState: AudioMediaPlayingState, totalTimeInMs: AudioState.TotalTimeInMs, currentPositionInMs: Int, + audioSpeed: AudioSpeed, + waveMask: List, onPlayButtonClick: () -> Unit, onSliderPositionChange: (Float) -> Unit, + onAudioSpeedChange: () -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -97,8 +105,11 @@ fun AudioMessage( audioMediaPlayingState = audioMediaPlayingState, totalTimeInMs = totalTimeInMs, currentPositionInMs = currentPositionInMs, + audioSpeed = audioSpeed, + waveMask = waveMask, onPlayButtonClick = onPlayButtonClick, onSliderPositionChange = onSliderPositionChange, + onAudioSpeedChange = onAudioSpeedChange ) } } @@ -109,6 +120,7 @@ fun RecordedAudioMessage( audioMediaPlayingState: AudioMediaPlayingState, totalTimeInMs: AudioState.TotalTimeInMs, currentPositionInMs: Int, + waveMask: List, onPlayButtonClick: () -> Unit, onSliderPositionChange: (Float) -> Unit, modifier: Modifier = Modifier, @@ -124,8 +136,11 @@ fun RecordedAudioMessage( audioMediaPlayingState = audioMediaPlayingState, totalTimeInMs = totalTimeInMs, currentPositionInMs = currentPositionInMs, + audioSpeed = AudioSpeed.NORMAL, + waveMask = waveMask, onPlayButtonClick = onPlayButtonClick, onSliderPositionChange = onSliderPositionChange, + onAudioSpeedChange = null ) } } @@ -135,8 +150,11 @@ private fun SuccessfulAudioMessage( audioMediaPlayingState: AudioMediaPlayingState, totalTimeInMs: AudioState.TotalTimeInMs, currentPositionInMs: Int, + audioSpeed: AudioSpeed, + waveMask: List, onPlayButtonClick: () -> Unit, onSliderPositionChange: (Float) -> Unit, + onAudioSpeedChange: (() -> Unit)?, modifier: Modifier = Modifier, ) { val audioDuration by remember(currentPositionInMs) { @@ -167,25 +185,55 @@ private fun SuccessfulAudioMessage( AudioMessageSlider( audioDuration = audioDuration, totalTimeInMs = totalTimeInMs, + waveMask = waveMask, onSliderPositionChange = onSliderPositionChange ) Row { Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(vertical = MaterialTheme.wireDimensions.spacing2x), text = audioDuration.formattedCurrentTime(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.wireColorScheme.primary, maxLines = 1 ) + if (audioMediaPlayingState is AudioMediaPlayingState.Playing && onAudioSpeedChange != null) { + WirePrimaryButton( + onClick = onAudioSpeedChange, + text = stringResource(audioSpeed.titleRes), + textStyle = MaterialTheme.wireTypography.label03, + contentPadding = PaddingValues( + horizontal = MaterialTheme.wireDimensions.spacing4x, + vertical = MaterialTheme.wireDimensions.spacing2x + ), + shape = RoundedCornerShape(MaterialTheme.wireDimensions.corner4x), + minSize = DpSize( + dimensions().spacing32x, + dimensions().spacing16x + ), + minClickableSize = DpSize( + dimensions().spacing40x, + dimensions().spacing16x + ), + fillMaxWidth = false + ) + } + Spacer(Modifier.weight(1F)) if (audioMediaPlayingState is AudioMediaPlayingState.Fetching) { WireCircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterVertically), progressColor = MaterialTheme.wireColorScheme.secondaryButtonEnabled ) } else { Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(vertical = MaterialTheme.wireDimensions.spacing2x), text = audioDuration.formattedTotalTime(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.wireColorScheme.secondaryText, @@ -211,34 +259,56 @@ private fun SuccessfulAudioMessage( private fun AudioMessageSlider( audioDuration: AudioDuration, totalTimeInMs: AudioState.TotalTimeInMs, + waveMask: List, onSliderPositionChange: (Float) -> Unit, ) { - // cyka check this for waves https://stackoverflow.com/questions/38744579/show-waveform-of-audio - Slider( - value = audioDuration.currentPositionInMs.toFloat(), - onValueChange = onSliderPositionChange, - valueRange = 0f..if (totalTimeInMs is AudioState.TotalTimeInMs.Known) totalTimeInMs.value.toFloat() else 0f, - thumb = { - SliderDefaults.Thumb( - interactionSource = remember { MutableInteractionSource() }, - thumbSize = DpSize(dimensions().spacing4x, dimensions().spacing32x) - ) - }, - track = { sliderState -> - SliderDefaults.Track( - modifier = Modifier.height(dimensions().spacing4x), - sliderState = sliderState, - thumbTrackGapSize = dimensions().spacing0x, - drawStopIndicator = { - // nop we do not want to draw stop indicator at all. - } - ) - }, - colors = SliderDefaults.colors( - inactiveTrackColor = colorsScheme().secondaryButtonDisabledOutline - ), - modifier = Modifier.fillMaxWidth() - ) + Box(modifier = Modifier.fillMaxWidth()) { + val totalMs = if (totalTimeInMs is AudioState.TotalTimeInMs.Known) totalTimeInMs.value.toFloat() else 0f + val waves = waveMask.ifEmpty { getDefaultWaveMask() } + val wavesAmount = waves.size + + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically + ) { + waves.forEachIndexed { index, wave -> + val isWaveActivated = totalMs > 0 && (index / wavesAmount.toFloat()) < audioDuration.currentPositionInMs / totalMs + Spacer( + Modifier + .background( + color = if (isWaveActivated) colorsScheme().primary else colorsScheme().onTertiaryButtonDisabled, + shape = RoundedCornerShape(dimensions().corner2x) + ) + .weight(2f) + .height(wave.dp) + ) + + Spacer(Modifier.weight(1F)) + } + } + + Slider( + value = audioDuration.currentPositionInMs.toFloat(), + onValueChange = onSliderPositionChange, + valueRange = 0f..totalMs, + thumb = { + SliderDefaults.Thumb( + interactionSource = remember { MutableInteractionSource() }, + thumbSize = DpSize(dimensions().spacing4x, dimensions().spacing32x) + ) + }, + track = { _ -> + // just empty, track is displayed by waves above + Spacer(Modifier.fillMaxWidth()) + }, + colors = SliderDefaults.colors( + inactiveTrackColor = colorsScheme().secondaryButtonDisabledOutline + ), + modifier = Modifier.fillMaxWidth() + ) + } } @Composable @@ -291,6 +361,14 @@ private fun getPlayOrPauseIcon(audioMediaPlayingState: AudioMediaPlayingState): else -> R.drawable.ic_play to R.string.content_description_play_audio } +private fun getDefaultWaveMask(): List { + val result = mutableListOf() + for (i in 0..75) { + result.add(1) + } + return result +} + // helper wrapper class to format the time that is left private data class AudioDuration(val totalDurationInMs: AudioState.TotalTimeInMs, val currentPositionInMs: Int) { companion object { @@ -346,8 +424,15 @@ private fun PreviewSuccessfulAudioMessage() { audioMediaPlayingState = AudioMediaPlayingState.Completed, totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), currentPositionInMs = 5000, + audioSpeed = AudioSpeed.NORMAL, + waveMask = listOf( + 32, 1, 24, 23, 13, 16, 9, 0, 4, 30, 23, 12, 14, 1, 7, 8, 0, 12, 32, 23, 34, 4, 16, 9, 0, 4, 30, 23, 12, + 14, 1, 7, 8, 0, 13, 16, 9, 0, 4, 30, 23, 12, 14, 1, 7, 8, 0, 12, 32, 23, 34, 4, 16, 13, 16, 9, 0, 4, 30, 23, 12, 14, 1, + 7, 8, 0, 12, 32, 23, 34, 4, 16, + ), onPlayButtonClick = {}, - onSliderPositionChange = {} + onSliderPositionChange = {}, + onAudioSpeedChange = {} ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioButtons.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioButtons.kt index 1356c670ba0..e3a592d80c3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioButtons.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioButtons.kt @@ -200,6 +200,7 @@ fun RecordAudioButtonSend( audioMediaPlayingState = audioState.audioMediaPlayingState, totalTimeInMs = audioState.totalTimeInMs, currentPositionInMs = audioState.currentPositionInMs, + waveMask = audioState.wavesMask, onPlayButtonClick = onPlayAudio, onSliderPositionChange = { position -> onSliderPositionChange(position.toInt()) @@ -230,7 +231,7 @@ private fun RecordAudioButton( isAudioFilterEnabled: Boolean = true, loading: Boolean = false, trailingIconAlignment: IconAlignment = IconAlignment.Border, - ) { +) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally @@ -323,7 +324,8 @@ fun PreviewRecordAudioButtonSend() { audioState = AudioState( audioMediaPlayingState = AudioMediaPlayingState.Paused, totalTimeInMs = AudioState.TotalTimeInMs.Known(1000), - currentPositionInMs = 0 + currentPositionInMs = 0, + wavesMask = listOf(32, 1, 24, 23, 13, 16, 9, 0, 4, 30, 23) ), onClick = {}, modifier = Modifier, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt index bcc95331587..e6fc0b029ff 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt @@ -28,6 +28,7 @@ import com.wire.android.appLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.RecordAudioMessagePlayer import com.wire.android.ui.home.conversations.model.UriAsset import com.wire.android.util.CurrentScreen @@ -64,6 +65,7 @@ class RecordAudioViewModel @Inject constructor( private val currentScreenManager: CurrentScreenManager, private val audioMediaRecorder: AudioMediaRecorder, private val globalDataStore: GlobalDataStore, + private val audioWavesMaskHelper: AudioWavesMaskHelper, private val dispatchers: DispatcherProvider, private val kaliumFileSystem: KaliumFileSystem ) : ViewModel() { @@ -201,17 +203,19 @@ class RecordAudioViewModel @Inject constructor( ) } + val playableAudioFile = getPlayableAudioFile() state = state.copy( buttonState = RecordAudioButtonState.READY_TO_SEND, audioState = AudioState.DEFAULT.copy( totalTimeInMs = AudioState.TotalTimeInMs.Known( - getPlayableAudioFile()?.let { + playableAudioFile?.let { getAudioLengthInMs( dataPath = it.path.toPath(), mimeType = SUPPORTED_AUDIO_MIME_TYPE ).toInt() } ?: 0 - ) + ), + wavesMask = playableAudioFile?.let { audioWavesMaskHelper.getWaveMask(it) } ?: listOf() ) ) } @@ -382,7 +386,8 @@ class RecordAudioViewModel @Inject constructor( dataPath = effectsFile.path.toPath(), mimeType = SUPPORTED_AUDIO_MIME_TYPE ).toInt() - ) + ), + wavesMask = listOf() ), shouldApplyEffects = true ) @@ -403,6 +408,7 @@ class RecordAudioViewModel @Inject constructor( override fun onCleared() { super.onCleared() recordAudioMessagePlayer.close() + audioWavesMaskHelper.clear() } companion object { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8eac9bc9e1..a4f1f5846c0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1309,6 +1309,9 @@ In group conversations, the group admin can overwrite this setting. Revoke Link Audio not available Something went wrong while downloading this audio file. Please ask the sender to upload it again + 1x + 1.5x + 2x Link couldn\'t be created. Please try again Link couldn\'t be revoked. Please try again New guests will not be able to join with this link. Current guests will still have access. diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index d761d47ca4c..3da79da03b2 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -24,6 +24,7 @@ import app.cash.turbine.test import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.auth.AccountInfo @@ -38,6 +39,7 @@ import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest +import okio.Path import org.junit.jupiter.api.Test @Suppress("LongMethod") @@ -477,16 +479,23 @@ class Arrangement { @MockK lateinit var mediaPlayer: MediaPlayer + @MockK + lateinit var wavesMaskHelper: AudioWavesMaskHelper + private val conversationAudioMessagePlayer by lazy { ConversationAudioMessagePlayer( context, mediaPlayer, + wavesMaskHelper, coreLogic, ) } init { MockKAnnotations.init(this, relaxed = true) + + every { wavesMaskHelper.getWaveMask(any()) } returns listOf() + every { wavesMaskHelper.clear() } returns Unit } fun withCurrentSession() = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index bc48d1c4fe4..3a5994d572d 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.paging.PagingData import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider @@ -154,6 +155,9 @@ class ConversationMessagesViewModelArrangement { } returns GetSearchedConversationMessagePositionUseCase.Result.Success(position = 0) coEvery { observeAssetStatuses(any()) } returns flowOf(mapOf()) + + coEvery { conversationAudioMessagePlayer.audioSpeed } returns flowOf(AudioSpeed.NORMAL) + coEvery { conversationAudioMessagePlayer.fetchWavesMask(any(), any()) } returns Unit } fun withSuccessfulViewModelInit() = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt index a8a9c1ee02c..e4cf7d8190b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt @@ -24,6 +24,7 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.datastore.GlobalDataStore import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.RecordAudioMessagePlayer import com.wire.android.ui.home.messagecomposer.recordaudio.RecordAudioViewModelTest.Arrangement.Companion.ASSET_SIZE_LIMIT import com.wire.android.util.CurrentScreen @@ -45,9 +46,11 @@ import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import okio.Path import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import java.io.File @ExtendWith(CoroutineTestExtension::class) class RecordAudioViewModelTest { @@ -356,6 +359,7 @@ class RecordAudioViewModelTest { val context = mockk() val dispatchers = TestDispatcherProvider() val fakeKaliumFileSystem = FakeKaliumFileSystem() + val audioWavesMaskHelper = mockk() val viewModel by lazy { RecordAudioViewModel( @@ -368,6 +372,7 @@ class RecordAudioViewModelTest { generateAudioFileWithEffects = generateAudioFileWithEffects, globalDataStore = globalDataStore, dispatchers = dispatchers, + audioWavesMaskHelper = audioWavesMaskHelper, kaliumFileSystem = fakeKaliumFileSystem ) } @@ -401,6 +406,9 @@ class RecordAudioViewModelTest { coEvery { recordAudioMessagePlayer.close() } returns Unit coEvery { observeEstablishedCalls() } returns flowOf(listOf()) + + every { audioWavesMaskHelper.getWaveMask(any()) } returns listOf() + every { audioWavesMaskHelper.getWaveMask(any()) } returns listOf() } fun withEstablishedCall() = apply { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b10ac7e975d..0e0e123bee4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ androidx-biometric = "1.1.0" androidx-startup = "1.1.1" androidx-compose-runtime = "1.7.1" compose-qr = "1.0.1" +amplituda = "2.2.2" # Compose composeBom = "2024.10.00" @@ -250,6 +251,8 @@ countly-sdk = { module = "ly.count.android:sdk", version.ref = "countly" } # QRs compose-qr-code = { module = "com.lightspark:compose-qr-code", version.ref = "compose-qr" } +audio-amplituda = { module = "com.github.lincollincol:amplituda", version.ref = "amplituda" } + # Dev tools aboutLibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutLibraries-ui = { module = "com.mikepenz:aboutlibraries-compose", version.ref = "aboutLibraries" } From c79f7effa50f282cf1241658773124ea2a610e3e Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Mon, 9 Dec 2024 15:24:09 +0200 Subject: [PATCH 04/19] Fixed code style --- .../android/media/audiomessage/AudioState.kt | 7 +-- .../audiomessage/AudioWavesMaskHelper.kt | 2 +- .../ConversationAudioMessagePlayer.kt | 43 ++++++++----------- .../media/ConversationMediaScreen.kt | 3 -- .../messagetypes/audio/AudioMessageType.kt | 9 +--- 5 files changed, 25 insertions(+), 39 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt index aa5ef18ef02..63346ae97fe 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt @@ -47,6 +47,7 @@ data class AudioState( } } +@Suppress("MagicNumber") enum class AudioSpeed(val value: Float, @StringRes val titleRes: Int) { NORMAL(1f, R.string.audio_speed_1), FAST(1.5f, R.string.audio_speed_1_5), @@ -60,9 +61,9 @@ enum class AudioSpeed(val value: Float, @StringRes val titleRes: Int) { companion object { fun fromFloat(speed: Float): AudioSpeed = when { - (speed > 1.6) -> MAX - (speed > 1) -> FAST - else -> NORMAL + (speed < FAST.value) -> NORMAL + (speed < MAX.value) -> FAST + else -> MAX } } } diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt index 39198db3739..dd5ef245404 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt @@ -51,7 +51,7 @@ class AudioWavesMaskHelper @Inject constructor( private fun List.averageWavesMask(): List { val wavesSize = size - val sectionSize = (wavesSize.toFloat() / 75).roundToInt() + val sectionSize = (wavesSize.toFloat() / WAVES_AMOUNT).roundToInt() if (wavesSize < WAVES_AMOUNT || sectionSize == 1) return map { it.toDouble() } diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index cc4a3d10945..ca48f13665d 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -54,7 +54,9 @@ class ConversationAudioMessagePlayerProvider @Synchronized fun provide(): ConversationAudioMessagePlayer { - val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, wavesMaskHelper, coreLogic).also { player = it } + val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, wavesMaskHelper, coreLogic).also { + player = it + } usageCount++ return player @@ -255,16 +257,10 @@ internal constructor( if (currentAccountResult is CurrentSessionResult.Failure) return@launch audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - messageId, - AudioMediaPlayingState.Fetching - ) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Fetching) ) - val assetMessage = coreLogic - .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) - .messages - .getAssetMessage(conversationId, messageId) + val assetMessage = getAssetMessage(currentAccountResult, conversationId, messageId) when (val result = assetMessage.await()) { is MessageAssetResult.Success -> { @@ -278,10 +274,7 @@ internal constructor( val isFetchedAudioCurrentlyQueuedToPlay = messageId == currentAudioMessageId if (isFetchedAudioCurrentlyQueuedToPlay) { - audioMediaPlayer.setDataSource( - context, - Uri.parse(result.decodedAssetPath.toString()) - ) + audioMediaPlayer.setDataSource(context, Uri.parse(result.decodedAssetPath.toString())) audioMediaPlayer.prepare() audioMessageStateUpdate.emit( @@ -298,27 +291,18 @@ internal constructor( updateSpeedFlow() audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - messageId, - AudioMediaPlayingState.Playing - ) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Playing) ) audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.TotalTimeUpdate( - messageId, - audioMediaPlayer.duration - ) + AudioMediaPlayerStateUpdate.TotalTimeUpdate(messageId, audioMediaPlayer.duration) ) } } is MessageAssetResult.Failure -> { audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - messageId, - AudioMediaPlayingState.Failed - ) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Failed) ) } } @@ -360,6 +344,15 @@ internal constructor( } } + private suspend fun getAssetMessage( + currentAccountResult: CurrentSessionResult, + conversationId: ConversationId, + messageId: String + ) = coreLogic + .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) + .messages + .getAssetMessage(conversationId, messageId) + private suspend fun resumeAudio(messageId: String) { audioMediaPlayer.start() updateSpeedFlow() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index 19f8a8e1694..86a2d4223bc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R -import com.wire.android.media.audiomessage.AudioState import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination @@ -79,8 +78,6 @@ import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.ui.UIText import com.wire.android.util.ui.openDownloadFolder import com.wire.kalium.logic.data.id.ConversationId -import kotlinx.collections.immutable.PersistentMap -import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.launch @RootNavGraph diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt index d322f5aaa89..13dc86e67a8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt @@ -361,13 +361,8 @@ private fun getPlayOrPauseIcon(audioMediaPlayingState: AudioMediaPlayingState): else -> R.drawable.ic_play to R.string.content_description_play_audio } -private fun getDefaultWaveMask(): List { - val result = mutableListOf() - for (i in 0..75) { - result.add(1) - } - return result -} +@Suppress("MagicNumber") +private fun getDefaultWaveMask(): List = List(75) { 1 } // helper wrapper class to format the time that is left private data class AudioDuration(val totalDurationInMs: AudioState.TotalTimeInMs, val currentPositionInMs: Int) { From 15f33039183d64c6850379b6a727868a0c3f82be Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Mon, 9 Dec 2024 17:29:35 +0200 Subject: [PATCH 05/19] Fixed code style and some tests --- .../ConversationAudioMessagePlayer.kt | 1 + .../ConversationAudioMessagePlayerTest.kt | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index ca48f13665d..45fc0c3441b 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -72,6 +72,7 @@ class ConversationAudioMessagePlayerProvider } } +@Suppress("TooManyFunctions") class ConversationAudioMessagePlayer internal constructor( private val context: Context, diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index 3da79da03b2..d810c7523fe 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -40,6 +40,7 @@ import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import okio.Path +import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test @Suppress("LongMethod") @@ -73,6 +74,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[testAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[testAudioMessageId] assert(currentState != null) @@ -129,6 +135,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[testAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[testAudioMessageId] assert(currentState != null) @@ -198,6 +209,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageId] assert(currentState != null) @@ -282,6 +298,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageId] assert(currentState != null) @@ -409,6 +430,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[testAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[testAudioMessageId] assert(currentState != null) @@ -494,7 +520,7 @@ class Arrangement { init { MockKAnnotations.init(this, relaxed = true) - every { wavesMaskHelper.getWaveMask(any()) } returns listOf() + every { wavesMaskHelper.getWaveMask(any()) } returns WAVES_MASK every { wavesMaskHelper.clear() } returns Unit } @@ -529,4 +555,8 @@ class Arrangement { } fun arrange() = this to conversationAudioMessagePlayer + + companion object { + val WAVES_MASK = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 0) + } } From e9ef69cc7a7ed9e72c1c8e6477758b412663b1b1 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Mon, 9 Dec 2024 18:42:16 +0200 Subject: [PATCH 06/19] Fix tests --- .../media/ConversationAudioMessagePlayerTest.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index d810c7523fe..8f19d420150 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -249,6 +249,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[secondAudioMessageId] assert(currentState != null) @@ -340,6 +345,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[secondAudioMessageId] assert(currentState != null) @@ -374,6 +384,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageId] assert(currentState != null) From 182d04e534e7fb0de4b399a737c54983f001b977 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Tue, 10 Dec 2024 15:55:13 +0200 Subject: [PATCH 07/19] Updated tests --- .../messages/ConversationMessagesViewModel.kt | 4 +-- .../ConversationAudioMessagePlayerTest.kt | 22 ++++++++++++++++ .../MessageComposerViewModelArrangement.kt | 25 ++++++++++++++++++ .../ConversationMessagesViewModelTest.kt | 26 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index fe382067082..aae29382a21 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -222,7 +222,7 @@ class ConversationMessagesViewModel @Inject constructor( } val paginatedMessagesFlow = getMessageForConversation(conversationId, lastReadIndex) - .requestAudioWavesMaskIfNeeded() + .fetchAudioWavesMaskIfNeeded() .flowOn(dispatchers.io()) conversationViewState = conversationViewState.copy( @@ -450,7 +450,7 @@ class ConversationMessagesViewModel @Inject constructor( } // checking all the new messages if it's an AudioMessage and fetch WavesMask for it if so - private fun Flow>.requestAudioWavesMaskIfNeeded(): Flow> = + private fun Flow>.fetchAudioWavesMaskIfNeeded(): Flow> = map { it.map { message -> if (message.messageContent is UIMessageContent.AudioAssetMessage) { diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index 8f19d420150..fd380ebba63 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -19,10 +19,12 @@ package com.wire.android.media import android.content.Context import android.media.MediaPlayer +import android.media.PlaybackParams import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer @@ -501,6 +503,22 @@ class ConversationAudioMessagePlayerTest { } } + @Test + fun givenTheSuccessFullAssetFetch_whenAudioSpeedChanged_thenMediaPlayerParamsWereUpdated() = runTest { + val params = PlaybackParams() + val (arrangement, conversationAudioMessagePlayer) = Arrangement() + .withSuccessFullAssetFetch() + .withCurrentSession() + .withAudioMediaPlayerReturningParams(params) + .arrange() + + //when + conversationAudioMessagePlayer.setSpeed(AudioSpeed.MAX) + + //then + verify(exactly = 1) { arrangement.mediaPlayer.playbackParams = params.setSpeed(2F) } + } + private suspend fun TurbineTestContext.awaitAndAssertStateUpdate(assertion: (T) -> Unit) { val state = awaitItem() assert(state != null) @@ -569,6 +587,10 @@ class Arrangement { every { mediaPlayer.duration } returns total } + fun withAudioMediaPlayerReturningParams(params: PlaybackParams = PlaybackParams()) = apply { + every { mediaPlayer.playbackParams } returns params + } + fun arrange() = this to conversationAudioMessagePlayer companion object { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt index 231254312a6..c3d49835c84 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt @@ -35,6 +35,7 @@ import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.MessageTime import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.navArgs import com.wire.android.util.FileManager import com.wire.android.util.ui.UIText @@ -45,6 +46,7 @@ import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.InteractionAvailability import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.sync.SyncState +import com.wire.kalium.logic.data.user.AssetId import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.OtherUser import com.wire.kalium.logic.data.user.UserAssetId @@ -234,3 +236,26 @@ internal fun mockUITextMessage(id: String = "someId", userName: String = "mockUs every { it.messageContent } returns null } } + +internal fun mockUIAudioMessage(id: String = "someId", userName: String = "mockUserName"): UIMessage { + return mockk().also { + every { it.userAvatarData } returns UserAvatarData() + every { it.source } returns MessageSource.OtherUser + every { it.header } returns mockk().also { + every { it.messageId } returns id + every { it.username } returns UIText.DynamicString(userName) + every { it.showLegalHoldIndicator } returns false + every { it.messageTime } returns MessageTime(Instant.DISTANT_PAST) + every { it.messageStatus } returns MessageStatus( + flowStatus = MessageFlowStatus.Sent, + expirationStatus = ExpirationStatus.NotExpirable + ) + } + every { it.messageContent } returns UIMessageContent.AudioAssetMessage( + "assert_name", + ".mp4", + AssetId("value", "domain"), + 1000L + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index 157d0d0196e..2e823c53361 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -20,12 +20,14 @@ package com.wire.android.ui.home.conversations.messages import androidx.paging.PagingData import androidx.paging.map +import androidx.paging.testing.asSnapshot import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestMessage import com.wire.android.framework.TestMessage.GENERIC_ASSET_CONTENT import com.wire.android.ui.home.conversations.ConversationSnackbarMessages +import com.wire.android.ui.home.conversations.composer.mockUIAudioMessage import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogActiveState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogsState import com.wire.android.ui.home.conversations.composer.mockUITextMessage @@ -37,6 +39,8 @@ import io.mockk.coVerify import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.Path.Companion.toPath import org.amshove.kluent.internal.assertEquals @@ -298,4 +302,26 @@ class ConversationMessagesViewModelTest { ) assertEquals(expectedState, viewModel.deleteMessageDialogsState) } + + + @Test + fun `given the AudioMessage in list, when getting paging flow, then fetching the waveMask for AudioMessage is called`() = runTest { + // Given + val firstMessage = mockUITextMessage(id = "firstId") + val secondMessage = mockUIAudioMessage(id = "secondId") + val pagingData = PagingData.from(listOf(firstMessage, secondMessage)) + + val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withPaginatedMessagesReturning(pagingData) + .arrange() + + val job = launch { viewModel.conversationViewState.messages.asSnapshot() } + job.start() + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.conversationAudioMessagePlayer.fetchWavesMask(any(), any()) } + + job.cancel() + } } From a8ae13ceb191a3d89525136b0f9a4655bc393616 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Wed, 11 Dec 2024 12:31:21 +0200 Subject: [PATCH 08/19] Code style fixes --- .../wire/android/media/ConversationAudioMessagePlayerTest.kt | 4 ++-- .../messages/ConversationMessagesViewModelTest.kt | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index fd380ebba63..4f6dc7d8b73 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -512,10 +512,10 @@ class ConversationAudioMessagePlayerTest { .withAudioMediaPlayerReturningParams(params) .arrange() - //when + // when conversationAudioMessagePlayer.setSpeed(AudioSpeed.MAX) - //then + // then verify(exactly = 1) { arrangement.mediaPlayer.playbackParams = params.setSpeed(2F) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index 2e823c53361..d854a692c67 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -28,9 +28,9 @@ import com.wire.android.framework.TestMessage import com.wire.android.framework.TestMessage.GENERIC_ASSET_CONTENT import com.wire.android.ui.home.conversations.ConversationSnackbarMessages import com.wire.android.ui.home.conversations.composer.mockUIAudioMessage +import com.wire.android.ui.home.conversations.composer.mockUITextMessage import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogActiveState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogsState -import com.wire.android.ui.home.conversations.composer.mockUITextMessage import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.data.message.MessageContent import com.wire.kalium.logic.data.user.UserId @@ -303,7 +303,6 @@ class ConversationMessagesViewModelTest { assertEquals(expectedState, viewModel.deleteMessageDialogsState) } - @Test fun `given the AudioMessage in list, when getting paging flow, then fetching the waveMask for AudioMessage is called`() = runTest { // Given From b1862e8376fbc1c22de296b31d6ae17bd50294c3 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Mon, 16 Dec 2024 22:27:04 +0200 Subject: [PATCH 09/19] Added JumpToPlayingAudio Button --- .../ConversationAudioMessagePlayer.kt | 6 +- .../home/conversations/ConversationScreen.kt | 78 ++++++++++++++++++- .../messages/ConversationMessagesViewModel.kt | 41 ++++++++-- .../messages/ConversationMessagesViewState.kt | 9 ++- .../messagetypes/audio/AudioMessageType.kt | 39 +--------- .../wire/android/util/DateAndTimeParsers.kt | 4 + 6 files changed, 131 insertions(+), 46 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index 45fc0c3441b..53c04bf616b 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -104,7 +104,7 @@ internal constructor( extraBufferCapacity = 1 ) - private val audioSpeedFlow = MutableSharedFlow( + private val _audioSpeed = MutableSharedFlow( onBufferOverflow = BufferOverflow.DROP_OLDEST, extraBufferCapacity = 1, replay = 1 @@ -191,7 +191,7 @@ internal constructor( audioMessageStateHistory }.onStart { emit(audioMessageStateHistory) } - val audioSpeed: Flow = audioSpeedFlow.onStart { emit(AudioSpeed.NORMAL) } + val audioSpeed: Flow = _audioSpeed.onStart { emit(AudioSpeed.NORMAL) } private var currentAudioMessageId: String? = null @@ -381,7 +381,7 @@ internal constructor( private suspend fun updateSpeedFlow() { val currentSpeed = AudioSpeed.fromFloat(audioMediaPlayer.playbackParams.speed) - audioSpeedFlow.emit(currentSpeed) + _audioSpeed.emit(currentSpeed) } internal fun close() { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 43b301de070..c8979838185 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -27,19 +27,24 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandIn import androidx.compose.animation.shrinkOut import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.FloatingActionButtonDefaults @@ -65,6 +70,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.PagingData @@ -137,6 +143,7 @@ import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBack import com.wire.android.ui.home.conversations.messages.AudioMessagesState import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState +import com.wire.android.ui.home.conversations.messages.PlayingAudiMessage import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel import com.wire.android.ui.home.conversations.messages.item.MessageClickActions import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem @@ -160,7 +167,9 @@ import com.wire.android.ui.home.messagecomposer.state.rememberMessageComposerSta import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectMessageDialog import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.DateAndTimeParsers import com.wire.android.util.normalizeLink import com.wire.android.util.serverDate import com.wire.android.util.ui.PreviewMultipleThemes @@ -888,6 +897,7 @@ private fun ConversationScreen( selectedMessageId = conversationMessagesViewState.searchedMessageId, messageComposerStateHolder = messageComposerStateHolder, messages = conversationMessagesViewState.messages, + playingAudiMessage = conversationMessagesViewState.playingAudiMessage, onSendMessage = onSendMessage, onPingOptionClicked = onPingOptionClicked, onImagesPicked = onImagesPicked, @@ -994,6 +1004,7 @@ private fun ConversationScreenContent( onNavigateToReplyOriginalMessage: (UIMessage) -> Unit, openDrawingCanvas: () -> Unit, currentTimeInMillisFlow: Flow = flow {}, + playingAudiMessage: PlayingAudiMessage?, ) { val lazyPagingMessages = messages.collectAsLazyPagingItems() @@ -1033,7 +1044,8 @@ private fun ConversationScreenContent( conversationDetailsData = conversationDetailsData, selectedMessageId = selectedMessageId, interactionAvailability = messageComposerStateHolder.messageComposerViewState.value.interactionAvailability, - currentTimeInMillisFlow = currentTimeInMillisFlow + currentTimeInMillisFlow = currentTimeInMillisFlow, + playingAudiMessage = playingAudiMessage ) }, onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, @@ -1099,7 +1111,8 @@ fun MessageList( interactionAvailability: InteractionAvailability, clickActions: MessageClickActions.Content, modifier: Modifier = Modifier, - currentTimeInMillisFlow: Flow = flow { } + currentTimeInMillisFlow: Flow = flow { }, + playingAudiMessage: PlayingAudiMessage? ) { val prevItemCount = remember { mutableStateOf(lazyPagingMessages.itemCount) } val readLastMessageAtStartTriggered = remember { mutableStateOf(false) } @@ -1227,6 +1240,11 @@ fun MessageList( } } } + JumpToPlayingAudioButton( + lazyPagingMessages = lazyPagingMessages, + lazyListState = lazyListState, + playingAudiMessage = playingAudiMessage + ) JumpToLastMessageButton(lazyListState = lazyListState) } ) @@ -1361,6 +1379,62 @@ fun JumpToLastMessageButton( } } +@Composable +fun BoxScope.JumpToPlayingAudioButton( + lazyListState: LazyListState, + playingAudiMessage: PlayingAudiMessage?, + modifier: Modifier = Modifier, + lazyPagingMessages: LazyPagingItems, + coroutineScope: CoroutineScope = rememberCoroutineScope() +) { + val indexOfPlayedMessage = playingAudiMessage?.let { + lazyPagingMessages.itemSnapshotList + .indexOfFirst { playingAudiMessage.messageId == it?.header?.messageId } + } ?: -1 + + if (indexOfPlayedMessage < 0) return + + // todo cyka try to remember indexes + val visible = playingAudiMessage?.let { + val firstVisibleIndex = lazyListState.firstVisibleItemIndex + val lastVisibleIndex = firstVisibleIndex + lazyListState.layoutInfo.visibleItemsInfo.size + indexOfPlayedMessage in firstVisibleIndex..lastVisibleIndex + } ?: false + + if (!visible) return + + Row( + modifier = modifier + .align(Alignment.TopCenter) + .clickable { coroutineScope.launch { lazyListState.animateScrollToItem(indexOfPlayedMessage) } } + .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x) + .background( + color = colorsScheme().secondaryText, + shape = RoundedCornerShape(MaterialTheme.wireDimensions.buttonCornerSize) + ) + ) { + Icon( + modifier = Modifier.weight(1f), + painter = painterResource(id = R.drawable.ic_play), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.onPrimaryButtonEnabled + ) + Spacer(Modifier.width(dimensions().spacing8x)) + Text( + text = playingAudiMessage!!.authorName, + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.body04, + ) + Spacer(Modifier.width(dimensions().spacing8x)) + Text( + modifier = Modifier.weight(1f), + text = DateAndTimeParsers.audioMessageTime(playingAudiMessage.currentTimeMs.toLong()), + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.body04, + ) + } +} + private fun CoroutineScope.withSmoothScreenLoad(block: () -> Unit) = launch { val smoothAnimationDuration = 200.milliseconds delay(smoothAnimationDuration) // we wait a bit until the whole screen is loaded to show the animation properly diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index aae29382a21..b0c88f048f5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -28,6 +28,7 @@ import androidx.paging.PagingData import androidx.paging.map import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider import com.wire.android.model.SnackBarMessage @@ -67,7 +68,6 @@ import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePosit import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase -import com.wire.kalium.logic.functional.combine import com.wire.kalium.logic.functional.onFailure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toPersistentMap @@ -78,6 +78,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -186,12 +189,40 @@ class ConversationMessagesViewModel @Inject constructor( } private fun observeAudioPlayerState() { + val observableAudioMessagesState = conversationAudioMessagePlayer.observableAudioMessagesState + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) + + val playingMessageData = observableAudioMessagesState + .map { audioMessageStates -> + audioMessageStates.firstNotNullOfOrNull { (messageId, audioState) -> + if (audioState.audioMediaPlayingState == AudioMediaPlayingState.Playing) messageId + else null + } + }.distinctUntilChanged() + .map { messageId -> messageId?.let { getMessageByIdUseCase(conversationId, it) } } + .filterIsInstance() + .map { it?.message } + viewModelScope.launch { - conversationAudioMessagePlayer.observableAudioMessagesState - .combine(conversationAudioMessagePlayer.audioSpeed) - .collect { (audioMessageStates, audioSpeed) -> + combine( + observableAudioMessagesState, + conversationAudioMessagePlayer.audioSpeed, + playingMessageData + ) { audioMessageStates, audioSpeed, playingMessage -> + val audioMessagesState = AudioMessagesState(audioMessageStates.toPersistentMap(), audioSpeed) + val playingAudiMessage = playingMessage?.let { + PlayingAudiMessage( + messageId = playingMessage.id, + authorName = playingMessage.sender?.name ?: "", + currentTimeMs = audioMessageStates[playingMessage.id]?.currentPositionInMs ?: 0 + ) + } + audioMessagesState to playingAudiMessage + } + .collect { (audioMessagesState, playingAudiMessage) -> conversationViewState = conversationViewState.copy( - audioMessagesState = AudioMessagesState(audioMessageStates.toPersistentMap(), audioSpeed) + audioMessagesState = audioMessagesState, + playingAudiMessage = playingAudiMessage ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt index d6cb32bb568..84fd447120b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt @@ -37,7 +37,8 @@ data class ConversationMessagesViewState( val downloadedAssetDialogState: DownloadedAssetDialogVisibilityState = DownloadedAssetDialogVisibilityState.Hidden, val audioMessagesState: AudioMessagesState = AudioMessagesState(), val assetStatuses: PersistentMap = persistentMapOf(), - val searchedMessageId: String? = null + val searchedMessageId: String? = null, + val playingAudiMessage: PlayingAudiMessage? = null ) data class AudioMessagesState( @@ -45,6 +46,12 @@ data class AudioMessagesState( val audioSpeed: AudioSpeed = AudioSpeed.NORMAL ) +data class PlayingAudiMessage( + val messageId: String, + val authorName: String, + val currentTimeMs: Int +) + sealed class DownloadedAssetDialogVisibilityState { object Hidden : DownloadedAssetDialogVisibilityState() data class Displayed(val assetData: AssetBundle, val messageId: String) : DownloadedAssetDialogVisibilityState() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt index 13dc86e67a8..1e79cd8db6a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt @@ -70,6 +70,7 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.DateAndTimeParsers import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -364,51 +365,19 @@ private fun getPlayOrPauseIcon(audioMediaPlayingState: AudioMediaPlayingState): @Suppress("MagicNumber") private fun getDefaultWaveMask(): List = List(75) { 1 } -// helper wrapper class to format the time that is left +// helper wrapper class to format the time private data class AudioDuration(val totalDurationInMs: AudioState.TotalTimeInMs, val currentPositionInMs: Int) { companion object { - const val totalMsInSec = 1000 - const val totalSecInMin = 60 const val UNKNOWN_DURATION_LABEL = "-:--" } - fun formattedTimeLeft(): String { - if (totalDurationInMs is AudioState.TotalTimeInMs.Known) { - val totalTimeInSec = totalDurationInMs.value / totalMsInSec - val currentPositionInSec = currentPositionInMs / totalMsInSec - - val isTotalTimeInSecKnown = totalTimeInSec > 0 - - val timeLeft = if (!isTotalTimeInSecKnown) { - currentPositionInSec - } else { - totalTimeInSec - currentPositionInSec - } - - return formattedTime(timeLeft) - } - - return UNKNOWN_DURATION_LABEL - } - - fun formattedCurrentTime(): String = - formattedTime(currentPositionInMs / totalMsInSec) + fun formattedCurrentTime(): String = DateAndTimeParsers.audioMessageTime(currentPositionInMs.toLong()) fun formattedTotalTime(): String = if (totalDurationInMs is AudioState.TotalTimeInMs.Known) { - formattedTime(totalDurationInMs.value / totalMsInSec) + DateAndTimeParsers.audioMessageTime(totalDurationInMs.value.toLong()) } else { UNKNOWN_DURATION_LABEL } - - private fun formattedTime(timeSeconds: Int): String { - // sanity check, timeLeft, should not be smaller, however if the back-end makes mistake we - // will display a negative values, which we do not want - val minutes = if (timeSeconds < 0) 0 else timeSeconds / totalSecInMin - val seconds = if (timeSeconds < 0) 0 else timeSeconds % totalSecInMin - val formattedSeconds = String.format("%02d", seconds) - - return "$minutes:$formattedSeconds" - } } @PreviewMultipleThemes diff --git a/app/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt b/app/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt index 32fee5383eb..aa7fff52c2b 100644 --- a/app/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt +++ b/app/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt @@ -90,6 +90,8 @@ class DateAndTimeParsers private constructor() { private val messageTimeFormatter = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT, Locale.getDefault()).apply { this.timeZone = java.util.TimeZone.getDefault() } + private val audioMessageTimeFormat = DateTimeFormatter.ofPattern("mm:ss", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) @Deprecated("Date String parsing is discouraged and will be removed soon for direct Instant/DateTime versions") fun serverDate(stringDate: String): Date? { @@ -137,5 +139,7 @@ class DateAndTimeParsers private constructor() { } catch (e: Exception) { null } + + fun audioMessageTime(timeMs: Long): String = audioMessageTimeFormat.format(java.time.Instant.ofEpochMilli(timeMs)) } } From 5fee401e856ec3193dd2a4806556c469e9755350 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Tue, 17 Dec 2024 16:55:31 +0200 Subject: [PATCH 10/19] finished JumpToPlayingAudioButton --- .../android/di/accountScoped/MessageModule.kt | 6 + .../home/conversations/ConversationScreen.kt | 49 ++++---- .../messages/ConversationMessagesViewModel.kt | 41 +++--- .../messages/ConversationMessagesViewState.kt | 6 +- ...onversationMessagesViewModelArrangement.kt | 12 +- .../ConversationMessagesViewModelTest.kt | 118 ++++++++++++++++-- kalium | 2 +- 7 files changed, 174 insertions(+), 60 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index ad72e7b2fea..506dcbc30f5 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -34,6 +34,7 @@ import com.wire.kalium.logic.feature.message.GetNotificationsUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesByConversationUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase +import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.message.MarkMessagesAsNotifiedUseCase import com.wire.kalium.logic.feature.message.MessageScope import com.wire.kalium.logic.feature.message.ObserveMessageReactionsUseCase @@ -216,4 +217,9 @@ class MessageModule { @Provides fun provideRemoveMessageDraftUseCase(messageScope: MessageScope): RemoveMessageDraftUseCase = messageScope.removeMessageDraftUseCase + + @ViewModelScoped + @Provides + fun provideGetSenderNameByMessageIdUseCase(messageScope: MessageScope): GetSenderNameByMessageIdUseCase = + messageScope.getSenderNameByMessageId } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 560914f6d6d..d624f997f1f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -33,14 +33,13 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape @@ -72,6 +71,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems @@ -170,7 +170,6 @@ import com.wire.android.ui.home.messagecomposer.state.rememberMessageComposerSta import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectMessageDialog import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme -import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.DateAndTimeParsers import com.wire.android.util.normalizeLink @@ -937,7 +936,6 @@ private fun ConversationScreen( selectedMessageId = conversationMessagesViewState.searchedMessageId, messageComposerStateHolder = messageComposerStateHolder, messages = conversationMessagesViewState.messages, - playingAudiMessage = conversationMessagesViewState.playingAudiMessage, onSendMessage = onSendMessage, onPingOptionClicked = onPingOptionClicked, onImagesPicked = onImagesPicked, @@ -1044,7 +1042,6 @@ private fun ConversationScreenContent( onNavigateToReplyOriginalMessage: (UIMessage) -> Unit, openDrawingCanvas: () -> Unit, currentTimeInMillisFlow: Flow = flow {}, - playingAudiMessage: PlayingAudiMessage?, ) { val lazyPagingMessages = messages.collectAsLazyPagingItems() @@ -1084,8 +1081,7 @@ private fun ConversationScreenContent( conversationDetailsData = conversationDetailsData, selectedMessageId = selectedMessageId, interactionAvailability = messageComposerStateHolder.messageComposerViewState.value.interactionAvailability, - currentTimeInMillisFlow = currentTimeInMillisFlow, - playingAudiMessage = playingAudiMessage + currentTimeInMillisFlow = currentTimeInMillisFlow ) }, onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, @@ -1151,8 +1147,7 @@ fun MessageList( interactionAvailability: InteractionAvailability, clickActions: MessageClickActions.Content, modifier: Modifier = Modifier, - currentTimeInMillisFlow: Flow = flow { }, - playingAudiMessage: PlayingAudiMessage? + currentTimeInMillisFlow: Flow = flow { } ) { val prevItemCount = remember { mutableStateOf(lazyPagingMessages.itemCount) } val readLastMessageAtStartTriggered = remember { mutableStateOf(false) } @@ -1281,9 +1276,9 @@ fun MessageList( } } JumpToPlayingAudioButton( - lazyPagingMessages = lazyPagingMessages, lazyListState = lazyListState, - playingAudiMessage = playingAudiMessage + lazyPagingMessages = lazyPagingMessages, + playingAudiMessage = audioMessagesState.playingAudiMessage ) JumpToLastMessageButton(lazyListState = lazyListState) } @@ -1423,8 +1418,8 @@ fun JumpToLastMessageButton( fun BoxScope.JumpToPlayingAudioButton( lazyListState: LazyListState, playingAudiMessage: PlayingAudiMessage?, - modifier: Modifier = Modifier, lazyPagingMessages: LazyPagingItems, + modifier: Modifier = Modifier, coroutineScope: CoroutineScope = rememberCoroutineScope() ) { val indexOfPlayedMessage = playingAudiMessage?.let { @@ -1434,43 +1429,45 @@ fun BoxScope.JumpToPlayingAudioButton( if (indexOfPlayedMessage < 0) return - // todo cyka try to remember indexes - val visible = playingAudiMessage?.let { - val firstVisibleIndex = lazyListState.firstVisibleItemIndex - val lastVisibleIndex = firstVisibleIndex + lazyListState.layoutInfo.visibleItemsInfo.size - indexOfPlayedMessage in firstVisibleIndex..lastVisibleIndex - } ?: false + val firstVisibleIndex = lazyListState.firstVisibleItemIndex + val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: firstVisibleIndex - if (!visible) return + if (indexOfPlayedMessage in firstVisibleIndex..lastVisibleIndex) return Row( + verticalAlignment = Alignment.CenterVertically, modifier = modifier + .wrapContentWidth() .align(Alignment.TopCenter) + .padding(all = dimensions().spacing8x) .clickable { coroutineScope.launch { lazyListState.animateScrollToItem(indexOfPlayedMessage) } } - .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x) .background( color = colorsScheme().secondaryText, - shape = RoundedCornerShape(MaterialTheme.wireDimensions.buttonCornerSize) + shape = RoundedCornerShape(dimensions().corner16x) ) + .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x) ) { Icon( - modifier = Modifier.weight(1f), + modifier = Modifier.size(dimensions().systemMessageIconSize), painter = painterResource(id = R.drawable.ic_play), contentDescription = null, tint = MaterialTheme.wireColorScheme.onPrimaryButtonEnabled ) - Spacer(Modifier.width(dimensions().spacing8x)) Text( + modifier = Modifier + .padding(horizontal = dimensions().spacing8x) + .weight(1f, fill = false), text = playingAudiMessage!!.authorName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, color = colorsScheme().onPrimaryButtonEnabled, style = MaterialTheme.wireTypography.body04, ) - Spacer(Modifier.width(dimensions().spacing8x)) Text( - modifier = Modifier.weight(1f), + modifier = Modifier, text = DateAndTimeParsers.audioMessageTime(playingAudiMessage.currentTimeMs.toLong()), color = colorsScheme().onPrimaryButtonEnabled, - style = MaterialTheme.wireTypography.body04, + style = MaterialTheme.wireTypography.label03, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index b0c88f048f5..f7d59bedb2e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -65,6 +65,7 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase +import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase @@ -80,7 +81,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -110,7 +110,8 @@ class ConversationMessagesViewModel @Inject constructor( private val getConversationUnreadEventsCount: GetConversationUnreadEventsCountUseCase, private val clearUsersTypingEvents: ClearUsersTypingEventsUseCase, private val getSearchedConversationMessagePosition: GetSearchedConversationMessagePositionUseCase, - private val deleteMessage: DeleteMessageUseCase + private val deleteMessage: DeleteMessageUseCase, + private val getSenderNameByMessageId: GetSenderNameByMessageIdUseCase ) : SavedStateViewModel(savedStateHandle) { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() @@ -195,36 +196,34 @@ class ConversationMessagesViewModel @Inject constructor( val playingMessageData = observableAudioMessagesState .map { audioMessageStates -> audioMessageStates.firstNotNullOfOrNull { (messageId, audioState) -> - if (audioState.audioMediaPlayingState == AudioMediaPlayingState.Playing) messageId - else null + if (audioState.audioMediaPlayingState == AudioMediaPlayingState.Playing) messageId else null } - }.distinctUntilChanged() - .map { messageId -> messageId?.let { getMessageByIdUseCase(conversationId, it) } } - .filterIsInstance() - .map { it?.message } + } + .distinctUntilChanged() + .map { messageId -> + val senderNameResult = messageId?.let { getSenderNameByMessageId(conversationId, it) } + val senderName = if (senderNameResult is GetSenderNameByMessageIdUseCase.Result.Success) senderNameResult.name + else null + + messageId to senderName + } viewModelScope.launch { combine( observableAudioMessagesState, conversationAudioMessagePlayer.audioSpeed, playingMessageData - ) { audioMessageStates, audioSpeed, playingMessage -> - val audioMessagesState = AudioMessagesState(audioMessageStates.toPersistentMap(), audioSpeed) - val playingAudiMessage = playingMessage?.let { + ) { audioMessageStates, audioSpeed, (playingMessageId, playingMessageSenderName) -> + val playingAudiMessage = playingMessageId?.let { PlayingAudiMessage( - messageId = playingMessage.id, - authorName = playingMessage.sender?.name ?: "", - currentTimeMs = audioMessageStates[playingMessage.id]?.currentPositionInMs ?: 0 + messageId = playingMessageId, + authorName = playingMessageSenderName.orEmpty(), + currentTimeMs = audioMessageStates[playingMessageId]?.currentPositionInMs ?: 0 ) } - audioMessagesState to playingAudiMessage + AudioMessagesState(audioMessageStates.toPersistentMap(), audioSpeed, playingAudiMessage) } - .collect { (audioMessagesState, playingAudiMessage) -> - conversationViewState = conversationViewState.copy( - audioMessagesState = audioMessagesState, - playingAudiMessage = playingAudiMessage - ) - } + .collect { conversationViewState = conversationViewState.copy(audioMessagesState = it) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt index 84fd447120b..119139691d5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt @@ -37,13 +37,13 @@ data class ConversationMessagesViewState( val downloadedAssetDialogState: DownloadedAssetDialogVisibilityState = DownloadedAssetDialogVisibilityState.Hidden, val audioMessagesState: AudioMessagesState = AudioMessagesState(), val assetStatuses: PersistentMap = persistentMapOf(), - val searchedMessageId: String? = null, - val playingAudiMessage: PlayingAudiMessage? = null + val searchedMessageId: String? = null ) data class AudioMessagesState( val audioStates: PersistentMap = persistentMapOf(), - val audioSpeed: AudioSpeed = AudioSpeed.NORMAL + val audioSpeed: AudioSpeed = AudioSpeed.NORMAL, + val playingAudiMessage: PlayingAudiMessage? = null ) data class PlayingAudiMessage( diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index 3a5994d572d..01f5cb4d252 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -47,6 +47,7 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase +import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase @@ -116,6 +117,9 @@ class ConversationMessagesViewModelArrangement { @MockK lateinit var deleteMessage: DeleteMessageUseCase + @MockK + lateinit var getSenderNameByMessageId: GetSenderNameByMessageIdUseCase + private val viewModel: ConversationMessagesViewModel by lazy { ConversationMessagesViewModel( savedStateHandle, @@ -133,7 +137,8 @@ class ConversationMessagesViewModelArrangement { getConversationUnreadEventsCount, clearUsersTypingEvents, getSearchedConversationMessagePosition, - deleteMessage + deleteMessage, + getSenderNameByMessageId ) } @@ -158,6 +163,7 @@ class ConversationMessagesViewModelArrangement { coEvery { conversationAudioMessagePlayer.audioSpeed } returns flowOf(AudioSpeed.NORMAL) coEvery { conversationAudioMessagePlayer.fetchWavesMask(any(), any()) } returns Unit + coEvery { getSenderNameByMessageId(any(), any()) } returns GetSenderNameByMessageIdUseCase.Result.Success("User Name") } fun withSuccessfulViewModelInit() = apply { @@ -231,5 +237,9 @@ class ConversationMessagesViewModelArrangement { return this } + fun withGetSenderNameByMessageId(result: GetSenderNameByMessageIdUseCase.Result) = apply { + coEvery { getSenderNameByMessageId(any(), any()) } returns result + } + fun arrange() = this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index d854a692c67..18f19efd891 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -26,17 +26,23 @@ import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestMessage import com.wire.android.framework.TestMessage.GENERIC_ASSET_CONTENT +import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.AudioSpeed +import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.home.conversations.ConversationSnackbarMessages import com.wire.android.ui.home.conversations.composer.mockUIAudioMessage import com.wire.android.ui.home.conversations.composer.mockUITextMessage import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogActiveState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogsState +import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.data.message.MessageContent import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.GetConversationUnreadEventsCountUseCase +import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import io.mockk.coVerify import io.mockk.verify +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch @@ -57,7 +63,7 @@ class ConversationMessagesViewModelTest { fun `given an message ID, when downloading or fetching into internal storage, then should get message details by ID`() = runTest { val message = TestMessage.ASSET_MESSAGE val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withGetMessageAssetUseCaseReturning("path".toPath(), 42L) .withGetMessageByIdReturning(message) .arrange() @@ -81,7 +87,7 @@ class ConversationMessagesViewModelTest { ) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() .withGetMessageByIdReturning(message) - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withGetMessageAssetUseCaseReturning(assetDataPath, assetSize) .withSuccessfulOpenAssetMessage(assetMimeType, assetName, assetDataPath, assetSize, messageId) .arrange() @@ -109,7 +115,7 @@ class ConversationMessagesViewModelTest { content = MessageContent.Asset(GENERIC_ASSET_CONTENT.copy(name = assetName, mimeType = mimeType, sizeInBytes = assetSize)) ) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withGetMessageByIdReturning(message) .withGetMessageAssetUseCaseReturning(dataPath, assetSize) .withSuccessfulSaveAssetMessage(mimeType, assetName, dataPath, assetSize, messageId) @@ -133,7 +139,7 @@ class ConversationMessagesViewModelTest { val updatedPagingData = PagingData.from(listOf(secondMessage)) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .arrange() viewModel.conversationViewState.messages.test { @@ -147,7 +153,7 @@ class ConversationMessagesViewModelTest { @Test fun `given a message and a reaction, when toggleReaction is called, then should call ToggleReactionUseCase`() = runTest { val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .arrange() val messageId = "mID" @@ -164,7 +170,7 @@ class ConversationMessagesViewModelTest { fun `given getting UnreadEventsCount failed, then messages requested anyway`() = runTest { val (arrangement, _) = ConversationMessagesViewModelArrangement() .withConversationUnreadEventsCount(GetConversationUnreadEventsCountUseCase.Result.Failure(StorageFailure.DataNotFound)) - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .arrange() coVerify(exactly = 1) { arrangement.getMessagesForConversationUseCase(any(), 0) } @@ -174,7 +180,7 @@ class ConversationMessagesViewModelTest { fun `given getting UnreadEventsCount succeed, then messages requested with corresponding lastReadIndex`() = runTest { val (arrangement, _) = ConversationMessagesViewModelArrangement() .withConversationUnreadEventsCount(GetConversationUnreadEventsCountUseCase.Result.Success(12)) - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .arrange() coVerify(exactly = 1) { arrangement.getMessagesForConversationUseCase(any(), 12) } @@ -185,7 +191,7 @@ class ConversationMessagesViewModelTest { val userId = UserId("someID", "someDomain") val clientId = "someClientId" val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withResetSessionResult() .arrange() @@ -323,4 +329,100 @@ class ConversationMessagesViewModelTest { job.cancel() } + + @Test + fun `given an message ID, when some Audio is played, then should get message sender name by message ID`() = runTest { + val message = TestMessage.ASSET_MESSAGE + val audioState = AudioState.DEFAULT.copy( + audioMediaPlayingState = AudioMediaPlayingState.Playing, + totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), + currentPositionInMs = 300 + ) + val userName = "some name" + val expectedAudioMessagesState = AudioMessagesState( + audioStates = persistentMapOf(message.id to audioState), + audioSpeed = AudioSpeed.NORMAL, + playingAudiMessage = PlayingAudiMessage( + messageId = message.id, + authorName = userName, + currentTimeMs = audioState.currentPositionInMs + ) + ) + val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withGetSenderNameByMessageId(GetSenderNameByMessageIdUseCase.Result.Success(userName)) + .withObservableAudioMessagesState( + flowOf( + mapOf( + message.id to audioState.copy(currentPositionInMs = 100), + message.id to audioState + ) + ) + ) + .arrange() + + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.getSenderNameByMessageId(arrangement.conversationId, message.id) } + assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) + } + + @Test + fun `given an message ID, when getSenderNameByMessageId fails, then senderName in PlayingAudiMessage is empty`() = runTest { + val message = TestMessage.ASSET_MESSAGE + val audioState = AudioState.DEFAULT.copy( + audioMediaPlayingState = AudioMediaPlayingState.Playing, + totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), + currentPositionInMs = 300 + ) + val expectedAudioMessagesState = AudioMessagesState( + audioStates = persistentMapOf(message.id to audioState), + audioSpeed = AudioSpeed.NORMAL, + playingAudiMessage = PlayingAudiMessage( + messageId = message.id, + authorName = "", + currentTimeMs = audioState.currentPositionInMs + ) + ) + val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withGetSenderNameByMessageId(GetSenderNameByMessageIdUseCase.Result.Failure(CoreFailure.Unknown(null))) + .withObservableAudioMessagesState( + flowOf( + mapOf( + message.id to audioState.copy(currentPositionInMs = 100), + message.id to audioState + ) + ) + ) + .arrange() + + advanceUntilIdle() + + assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) + } + + @Test + fun `given an message ID, when no playing Audio message, then PlayingAudiMessage is null`() = runTest { + val message = TestMessage.ASSET_MESSAGE + val audioState = AudioState.DEFAULT.copy( + audioMediaPlayingState = AudioMediaPlayingState.Stopped, + totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), + currentPositionInMs = 300 + ) + val expectedAudioMessagesState = AudioMessagesState( + audioStates = persistentMapOf(message.id to audioState), + audioSpeed = AudioSpeed.NORMAL, + playingAudiMessage = null + ) + val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withObservableAudioMessagesState(flowOf(mapOf(message.id to audioState))) + .arrange() + + advanceUntilIdle() + + coVerify(exactly = 0) { arrangement.getSenderNameByMessageId(arrangement.conversationId, message.id) } + assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) + } } diff --git a/kalium b/kalium index 0667f9b780a..9e38f7d8410 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 0667f9b780a8262768b0c37af3d49d4f83c55701 +Subproject commit 9e38f7d84108797c36171b621eb716bc51bbe707 From 6ceb9b2dbcdfce52e856c8aceb9602c1285e3f5f Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Tue, 17 Dec 2024 17:03:58 +0200 Subject: [PATCH 11/19] Review updates --- .../android/media/audiomessage/AudioWavesMaskHelper.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt index dd5ef245404..c4bc1df831c 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt @@ -66,13 +66,8 @@ class AudioWavesMaskHelper @Inject constructor( } private fun List.averageInt(): Double { - var sum = 0.0 - var count = 0 - for (element in this) { - sum += element - ++count - } - return if (count == 0) 0.0 else sum / count + if (isEmpty()) return 0.0 + return sum().toDouble() / size } fun clear() { From 6c291365ca2c942899f1e79eb08ca0f32166cb74 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Tue, 17 Dec 2024 22:00:57 +0200 Subject: [PATCH 12/19] Code style fix --- .../messages/ConversationMessagesViewModel.kt | 7 ++++--- kalium | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index f7d59bedb2e..5377c40f8c2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -201,9 +201,10 @@ class ConversationMessagesViewModel @Inject constructor( } .distinctUntilChanged() .map { messageId -> - val senderNameResult = messageId?.let { getSenderNameByMessageId(conversationId, it) } - val senderName = if (senderNameResult is GetSenderNameByMessageIdUseCase.Result.Success) senderNameResult.name - else null + val senderName = messageId?.let { + val result = getSenderNameByMessageId(conversationId, it) + if (result is GetSenderNameByMessageIdUseCase.Result.Success) result.name else null + } messageId to senderName } diff --git a/kalium b/kalium index 9e38f7d8410..b4b66eb70a5 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 9e38f7d84108797c36171b621eb716bc51bbe707 +Subproject commit b4b66eb70a59f7f1c8178d75498d14a2cc4a0db8 From 4be2efe67d09316eb90f1f23567e387df7cdcea2 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Mon, 23 Dec 2024 12:20:00 +0200 Subject: [PATCH 13/19] feat: Audio message play in background --- .../wire/android/mapper/ConversationMapper.kt | 32 +- .../android/media/audiomessage/AudioState.kt | 29 +- .../ConversationAudioMessagePlayer.kt | 305 ++++++++++++------ .../home/conversations/ConversationScreen.kt | 86 ++--- .../messages/ConversationMessagesViewModel.kt | 57 +--- .../messages/ConversationMessagesViewState.kt | 9 +- .../GetConversationsFromSearchUseCase.kt | 4 +- .../ConversationListViewModel.kt | 30 +- .../ConversationsScreenContent.kt | 19 +- .../common/ConversationItemFactory.kt | 142 +++++++- .../common/ConversationList.kt | 18 +- .../model/ConversationItem.kt | 11 + .../recordaudio/RecordAudioViewModel.kt | 2 +- .../ConversationAudioMessagePlayerTest.kt | 137 ++++---- ...onversationMessagesViewModelArrangement.kt | 18 +- .../ConversationMessagesViewModelTest.kt | 66 +--- .../ConversationListViewModelTest.kt | 16 +- .../wire/android/ui/theme/WireDimensions.kt | 2 + kalium | 2 +- 19 files changed, 634 insertions(+), 351 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt index abda5b931af..60f329506fe 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -17,6 +17,8 @@ */ package com.wire.android.mapper +import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.model.ImageAsset.UserAvatarAsset import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData @@ -25,7 +27,9 @@ import com.wire.android.ui.home.conversationslist.model.BadgeEventType import com.wire.android.ui.home.conversationslist.model.BlockState import com.wire.android.ui.home.conversationslist.model.ConversationInfo import com.wire.android.ui.home.conversationslist.model.ConversationItem +import com.wire.android.ui.home.conversationslist.model.PlayingAudioInConversation import com.wire.android.ui.home.conversationslist.showLegalHoldIndicator +import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.ConversationDetails.Connection import com.wire.kalium.logic.data.conversation.ConversationDetails.Group import com.wire.kalium.logic.data.conversation.ConversationDetails.OneOne @@ -42,7 +46,8 @@ import com.wire.kalium.logic.data.user.UserAvailabilityStatus fun ConversationDetailsWithEvents.toConversationItem( userTypeMapper: UserTypeMapper, searchQuery: String, - selfUserTeamId: TeamId? + selfUserTeamId: TeamId?, + playingAudioMessage: PlayingAudioMessage ): ConversationItem = when (val conversationDetails = this.conversationDetails) { is Group -> { ConversationItem.GroupConversation( @@ -65,7 +70,8 @@ fun ConversationDetailsWithEvents.toConversationItem( proteusVerificationStatus = conversationDetails.conversation.proteusVerificationStatus, hasNewActivitiesToShow = hasNewActivitiesToShow, searchQuery = searchQuery, - isFavorite = conversationDetails.isFavorite + isFavorite = conversationDetails.isFavorite, + playingAudio = getPlayingAudioInConversation(playingAudioMessage, conversationDetails) ) } @@ -103,7 +109,8 @@ fun ConversationDetailsWithEvents.toConversationItem( proteusVerificationStatus = conversationDetails.conversation.proteusVerificationStatus, hasNewActivitiesToShow = hasNewActivitiesToShow, searchQuery = searchQuery, - isFavorite = conversationDetails.isFavorite + isFavorite = conversationDetails.isFavorite, + playingAudio = getPlayingAudioInConversation(playingAudioMessage, conversationDetails) ) } @@ -140,6 +147,25 @@ fun ConversationDetailsWithEvents.toConversationItem( } } +private fun getPlayingAudioInConversation( + playingAudioMessage: PlayingAudioMessage, + conversationDetails: ConversationDetails +): PlayingAudioInConversation? = + if (playingAudioMessage is PlayingAudioMessage.Some + && playingAudioMessage.conversationId == conversationDetails.conversation.id + ) { + if (playingAudioMessage.state.isPlaying()) { + PlayingAudioInConversation(playingAudioMessage.messageId, false) + } else if (playingAudioMessage.state.audioMediaPlayingState is AudioMediaPlayingState.Paused) { + PlayingAudioInConversation(playingAudioMessage.messageId, true) + } else { + // states Fetching, Completed, Stopped, etc. should not be shown in ConversationItem + null + } + } else { + null + } + private fun parseConnectionEventType(connectionState: ConnectionState) = if (connectionState == ConnectionState.SENT) { BadgeEventType.SentConnectRequest diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt index 63346ae97fe..8a4321f7898 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt @@ -19,6 +19,8 @@ package com.wire.android.media.audiomessage import androidx.annotation.StringRes import com.wire.android.R +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.id.ConversationId data class AudioState( val audioMediaPlayingState: AudioMediaPlayingState, @@ -40,6 +42,10 @@ data class AudioState( return totalTimeInMs } + fun isPlaying() = audioMediaPlayingState is AudioMediaPlayingState.Playing + fun isPlayingOrPaused() = audioMediaPlayingState is AudioMediaPlayingState.Playing + || audioMediaPlayingState is AudioMediaPlayingState.Paused + sealed class TotalTimeInMs { object NotKnown : TotalTimeInMs() @@ -47,6 +53,16 @@ data class AudioState( } } +sealed class PlayingAudioMessage { + data object None : PlayingAudioMessage() + data class Some( + val conversationId: ConversationId, + val messageId: String, + val authorName: UIText, + val state: AudioState + ) : PlayingAudioMessage() +} + @Suppress("MagicNumber") enum class AudioSpeed(val value: Float, @StringRes val titleRes: Int) { NORMAL(1f, R.string.audio_speed_1), @@ -84,27 +100,32 @@ sealed class AudioMediaPlayingState { } sealed class AudioMediaPlayerStateUpdate( + open val conversationId: ConversationId, open val messageId: String ) { data class AudioMediaPlayingStateUpdate( + override val conversationId: ConversationId, override val messageId: String, val audioMediaPlayingState: AudioMediaPlayingState - ) : AudioMediaPlayerStateUpdate(messageId) + ) : AudioMediaPlayerStateUpdate(conversationId, messageId) data class PositionChangeUpdate( + override val conversationId: ConversationId, override val messageId: String, val position: Int - ) : AudioMediaPlayerStateUpdate(messageId) + ) : AudioMediaPlayerStateUpdate(conversationId, messageId) data class TotalTimeUpdate( + override val conversationId: ConversationId, override val messageId: String, val totalTimeInMs: Int - ) : AudioMediaPlayerStateUpdate(messageId) + ) : AudioMediaPlayerStateUpdate(conversationId, messageId) data class WaveMaskUpdate( + override val conversationId: ConversationId, override val messageId: String, val waveMask: List - ) : AudioMediaPlayerStateUpdate(messageId) + ) : AudioMediaPlayerStateUpdate(conversationId, messageId) } sealed class RecordAudioMediaPlayerStateUpdate { diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index 53c04bf616b..b70cdb031b6 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -21,22 +21,31 @@ import android.content.Context import android.media.MediaPlayer import android.media.MediaPlayer.SEEK_CLOSEST_SYNC import android.net.Uri +import com.wire.android.R +import com.wire.android.di.ApplicationScope import com.wire.android.di.KaliumCoreLogic +import com.wire.android.util.extension.intervalFlow +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.asset.MessageAssetResult +import com.wire.kalium.logic.feature.message.GetNextAudioMessageInConversationUseCase +import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -48,13 +57,14 @@ class ConversationAudioMessagePlayerProvider private val audioMediaPlayer: MediaPlayer, private val wavesMaskHelper: AudioWavesMaskHelper, @KaliumCoreLogic private val coreLogic: CoreLogic, + @ApplicationScope private val scope: CoroutineScope ) { private var player: ConversationAudioMessagePlayer? = null private var usageCount: Int = 0 @Synchronized fun provide(): ConversationAudioMessagePlayer { - val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, wavesMaskHelper, coreLogic).also { + val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, wavesMaskHelper, coreLogic, scope).also { player = it } usageCount++ @@ -79,6 +89,7 @@ internal constructor( private val audioMediaPlayer: MediaPlayer, private val wavesMaskHelper: AudioWavesMaskHelper, @KaliumCoreLogic private val coreLogic: CoreLogic, + @ApplicationScope private val scope: CoroutineScope ) { private companion object { const val UPDATE_POSITION_INTERVAL_IN_MS = 100L @@ -89,15 +100,21 @@ internal constructor( if (currentAudioMessageId != null) { audioMessageStateUpdate.tryEmit( AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - currentAudioMessageId!!, + currentAudioMessageId!!.conversationId, + currentAudioMessageId!!.messageId, AudioMediaPlayingState.Completed ) ) seekToAudioPosition.tryEmit(currentAudioMessageId!! to 0) + + tryToPlayNextAudio() } } } + private var audioMessageStateHistory: Map = emptyMap() + private var currentAudioMessageId: MessageIdWrapper? = null + private val audioMessageStateUpdate = MutableSharedFlow( onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -110,41 +127,55 @@ internal constructor( replay = 1 ) - // MediaPlayer API does not have any mechanism that would inform as about the currentPosition, - // in a callback manner, therefore we need to create a timer manually that ticks every 1 second - // and emits the current position - private val mediaPlayerPosition = flow { - delay(UPDATE_POSITION_INTERVAL_IN_MS) - while (true) { - if (audioMediaPlayer.isPlaying) { - emit(currentAudioMessageId to audioMediaPlayer.currentPosition) - } - delay(UPDATE_POSITION_INTERVAL_IN_MS) - } - }.distinctUntilChanged() - private val seekToAudioPosition = - MutableSharedFlow>( + MutableSharedFlow>( onBufferOverflow = BufferOverflow.DROP_OLDEST, extraBufferCapacity = 1 ) + private val positionCheckTrigger = MutableSharedFlow() + + // MediaPlayer API does not have any mechanism that would inform as about the currentPosition, + // in a callback manner, therefore we need to create a timer manually that ticks every UPDATE_POSITION_INTERVAL_IN_MS + // and emits the current position + private val mediaPlayerPosition = positionCheckTrigger + .map { + currentAudioMessageId?.let { + audioMessageStateHistory[it]?.let { state -> state.isPlaying() } + } ?: false + } + .distinctUntilChanged() + .flatMapLatest { isAnythingPlaying -> + if (isAnythingPlaying) { + intervalFlow(UPDATE_POSITION_INTERVAL_IN_MS) + .map { + if (audioMediaPlayer.isPlaying) { + currentAudioMessageId to audioMediaPlayer.currentPosition + } else { + null + } + }.filterNotNull() + } else { + // no need for tick-tack checking if there on playing message + emptyFlow>() + } + } + private val positionChangedUpdate = merge(mediaPlayerPosition, seekToAudioPosition) .map { (messageId, position) -> messageId?.let { - AudioMediaPlayerStateUpdate.PositionChangeUpdate(it, position) + AudioMediaPlayerStateUpdate.PositionChangeUpdate(it.conversationId, it.messageId, position) } }.filterNotNull() - private var audioMessageStateHistory: Map = emptyMap() - // Flow collecting the audio message state updates as well as the audio message position // updates, the collected values are then put into the map holding the state for each individual audio message. // The audio message position can be either updated by the user manually by for example a Slider component or by the player itself. - val observableAudioMessagesState: Flow> = + val observableAudioMessagesState: Flow> = merge(positionChangedUpdate, audioMessageStateUpdate).map { audioMessageStateUpdate -> + val messageIdKey = MessageIdWrapper(audioMessageStateUpdate.conversationId, audioMessageStateUpdate.messageId) val currentState = audioMessageStateHistory.getOrDefault( - audioMessageStateUpdate.messageId, + messageIdKey, AudioState.DEFAULT ) @@ -152,16 +183,17 @@ internal constructor( is AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate -> { audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { put( - audioMessageStateUpdate.messageId, + messageIdKey, currentState.copy(audioMediaPlayingState = audioMessageStateUpdate.audioMediaPlayingState) ) } + positionCheckTrigger.emit(Unit) } is AudioMediaPlayerStateUpdate.PositionChangeUpdate -> { audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { put( - audioMessageStateUpdate.messageId, + messageIdKey, currentState.copy(currentPositionInMs = audioMessageStateUpdate.position) ) } @@ -170,7 +202,7 @@ internal constructor( is AudioMediaPlayerStateUpdate.TotalTimeUpdate -> { audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { put( - audioMessageStateUpdate.messageId, + messageIdKey, currentState.copy( totalTimeInMs = AudioState.TotalTimeInMs.Known(audioMessageStateUpdate.totalTimeInMs) ) @@ -181,7 +213,7 @@ internal constructor( is AudioMediaPlayerStateUpdate.WaveMaskUpdate -> { audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { put( - audioMessageStateUpdate.messageId, + messageIdKey, currentState.copy(wavesMask = audioMessageStateUpdate.waveMask) ) } @@ -189,25 +221,60 @@ internal constructor( } audioMessageStateHistory - }.onStart { emit(audioMessageStateHistory) } + }.shareIn(scope, SharingStarted.WhileSubscribed(), 1) + .onStart { emit(audioMessageStateHistory) } + + // Flow contains currently playing or last paused Audio message date. + // If there is such a message state is PlayingAudioMessageState.Some, + // PlayingAudioMessageState.None otherwise + val playingAudioMessageFlow: Flow = observableAudioMessagesState + .scan(PlayingAudioMessage.None as PlayingAudioMessage) { prevState, statesHistory -> + val currentMessageId = currentAudioMessageId + val state = currentMessageId?.let { statesHistory[it] } + + when { + (state?.isPlayingOrPaused() != true) -> PlayingAudioMessage.None + + (prevState is PlayingAudioMessage.Some && prevState.messageId == currentMessageId.messageId) -> + // no need to request Sender name if we already have it + PlayingAudioMessage.Some( + conversationId = currentMessageId.conversationId, + messageId = currentMessageId.messageId, + authorName = prevState.authorName, + state = state + ) - val audioSpeed: Flow = _audioSpeed.onStart { emit(AudioSpeed.NORMAL) } + else -> { + val authorName = getSenderNameByMessageId(currentMessageId.conversationId, currentMessageId.messageId) + ?.let { UIText.DynamicString(it) } + ?: UIText.StringResource(R.string.username_unavailable_label) - private var currentAudioMessageId: String? = null + PlayingAudioMessage.Some( + conversationId = currentMessageId.conversationId, + messageId = currentMessageId.messageId, + authorName = authorName, + state = state, + ) + } + } + } + .shareIn(scope, SharingStarted.WhileSubscribed(), 1) - suspend fun playAudio( + val audioSpeed: Flow = _audioSpeed.onStart { emit(AudioSpeed.NORMAL) } + + fun playAudio( conversationId: ConversationId, requestedAudioMessageId: String ) { - val isRequestedAudioMessageCurrentlyPlaying = currentAudioMessageId == requestedAudioMessageId + val isRequestedAudioMessageCurrentlyPlaying = currentAudioMessageId == MessageIdWrapper(conversationId, requestedAudioMessageId) if (isRequestedAudioMessageCurrentlyPlaying) { - resumeOrPauseCurrentlyPlayingAudioMessage(requestedAudioMessageId) + resumeOrPauseCurrentlyPlayingAudioMessage(conversationId, requestedAudioMessageId) } else { stopCurrentlyPlayingAudioMessage() playAudioMessage( conversationId = conversationId, messageId = requestedAudioMessageId, - position = previouslyResumedPosition(requestedAudioMessageId) + position = previouslyResumedPosition(conversationId, requestedAudioMessageId) ) } } @@ -218,8 +285,8 @@ internal constructor( updateSpeedFlow() } - private fun previouslyResumedPosition(requestedAudioMessageId: String): Int? { - return audioMessageStateHistory[requestedAudioMessageId]?.run { + private fun previouslyResumedPosition(conversationId: ConversationId, requestedAudioMessageId: String): Int? { + return audioMessageStateHistory[MessageIdWrapper(conversationId, requestedAudioMessageId)]?.run { if (audioMediaPlayingState == AudioMediaPlayingState.Completed) { 0 } else { @@ -228,101 +295,113 @@ internal constructor( } } - private suspend fun stopCurrentlyPlayingAudioMessage() { - currentAudioMessageId?.let { - val currentAudioState = audioMessageStateHistory[it] - if (currentAudioState?.audioMediaPlayingState != AudioMediaPlayingState.Fetching) { - stop(it) + fun stopCurrentlyPlayingAudioMessage() { + scope.launch { + currentAudioMessageId?.let { + val currentAudioState = audioMessageStateHistory[it] + if (currentAudioState?.audioMediaPlayingState != AudioMediaPlayingState.Fetching) { + stop(it.conversationId, it.messageId) + } } } } - private suspend fun resumeOrPauseCurrentlyPlayingAudioMessage(messageId: String) { - if (audioMediaPlayer.isPlaying) { - pause(messageId) - } else { - resumeAudio(messageId) + fun resumeOrPauseCurrentlyPlayingAudioMessage(conversationId: ConversationId, messageId: String) { + scope.launch { + if (audioMediaPlayer.isPlaying) { + pause(conversationId, messageId) + } else { + resumeAudio(conversationId, messageId) + } } } - private suspend fun playAudioMessage( + private fun playAudioMessage( conversationId: ConversationId, messageId: String, position: Int? = null ) { - currentAudioMessageId = messageId + currentAudioMessageId = MessageIdWrapper(conversationId, messageId) - coroutineScope { - launch { - val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() - if (currentAccountResult is CurrentSessionResult.Failure) return@launch + scope.launch { + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Failure) return@launch - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Fetching) - ) + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Fetching) + ) - val assetMessage = getAssetMessage(currentAccountResult, conversationId, messageId) + val assetMessage = getAssetMessage(currentAccountResult, conversationId, messageId) - when (val result = assetMessage.await()) { - is MessageAssetResult.Success -> { - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - messageId, - AudioMediaPlayingState.SuccessfulFetching - ) + when (val result = assetMessage.await()) { + is MessageAssetResult.Success -> { + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( + conversationId, + messageId, + AudioMediaPlayingState.SuccessfulFetching ) + ) - val isFetchedAudioCurrentlyQueuedToPlay = messageId == currentAudioMessageId + val isFetchedAudioCurrentlyQueuedToPlay = MessageIdWrapper(conversationId, messageId) == currentAudioMessageId - if (isFetchedAudioCurrentlyQueuedToPlay) { - audioMediaPlayer.setDataSource(context, Uri.parse(result.decodedAssetPath.toString())) - audioMediaPlayer.prepare() + if (isFetchedAudioCurrentlyQueuedToPlay) { + audioMediaPlayer.setDataSource(context, Uri.parse(result.decodedAssetPath.toString())) + audioMediaPlayer.prepare() - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.WaveMaskUpdate( - messageId, - wavesMaskHelper.getWaveMask(result.decodedAssetPath) - ) + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.WaveMaskUpdate( + conversationId, + messageId, + wavesMaskHelper.getWaveMask(result.decodedAssetPath) ) + ) - if (position != null) audioMediaPlayer.seekTo(position) - - audioMediaPlayer.start() + if (position != null) audioMediaPlayer.seekTo(position) - updateSpeedFlow() + audioMediaPlayer.start() - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Playing) - ) + updateSpeedFlow() - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.TotalTimeUpdate(messageId, audioMediaPlayer.duration) + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( + conversationId, + messageId, + AudioMediaPlayingState.Playing ) - } - } + ) - is MessageAssetResult.Failure -> { audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Failed) + AudioMediaPlayerStateUpdate.TotalTimeUpdate(conversationId, messageId, audioMediaPlayer.duration) ) } } + + is MessageAssetResult.Failure -> { + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( + conversationId, + messageId, + AudioMediaPlayingState.Failed + ) + ) + } } } } - suspend fun setPosition(messageId: String, position: Int) { - val currentAudioState = audioMessageStateHistory[messageId] + suspend fun setPosition(conversationId: ConversationId, messageId: String, position: Int) { + val currentAudioState = audioMessageStateHistory[MessageIdWrapper(conversationId, messageId)] if (currentAudioState != null) { - val isAudioMessageCurrentlyPlaying = currentAudioMessageId == messageId + val isAudioMessageCurrentlyPlaying = currentAudioMessageId == MessageIdWrapper(conversationId, messageId) if (isAudioMessageCurrentlyPlaying) { audioMediaPlayer.seekTo(position.toLong(), SEEK_CLOSEST_SYNC) } } - seekToAudioPosition.emit(messageId to position) + seekToAudioPosition.emit(MessageIdWrapper(conversationId, messageId) to position) } suspend fun fetchWavesMask(conversationId: ConversationId, messageId: String) { @@ -338,6 +417,7 @@ internal constructor( if (result is MessageAssetResult.Success) { audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.WaveMaskUpdate( + conversationId, messageId, wavesMaskHelper.getWaveMask(result.decodedAssetPath) ) @@ -354,28 +434,28 @@ internal constructor( .messages .getAssetMessage(conversationId, messageId) - private suspend fun resumeAudio(messageId: String) { + private suspend fun resumeAudio(conversationId: ConversationId, messageId: String) { audioMediaPlayer.start() updateSpeedFlow() audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Playing) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Playing) ) } - private suspend fun pause(messageId: String) { + private suspend fun pause(conversationId: ConversationId, messageId: String) { audioMediaPlayer.pause() audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Paused) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Paused) ) } - private suspend fun stop(messageId: String) { + private suspend fun stop(conversationId: ConversationId, messageId: String) { audioMediaPlayer.reset() audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Stopped) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Stopped) ) } @@ -384,8 +464,41 @@ internal constructor( _audioSpeed.emit(currentSpeed) } + private fun tryToPlayNextAudio() { + scope.launch { + currentAudioMessageId?.let { (conversationId, currentMessageId) -> + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Failure) return@launch + + val nextAudio = coreLogic + .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) + .messages + .getNextAudioMessageInConversation(conversationId, currentMessageId) + + if (nextAudio is GetNextAudioMessageInConversationUseCase.Result.Success) { + stop(conversationId, currentMessageId) + playAudioMessage(conversationId, nextAudio.messageId) + } + } + } + } + + private suspend fun getSenderNameByMessageId(conversationId: ConversationId, messageId: String): String? { + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Failure) return null + + val senderNameResult = coreLogic + .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) + .messages + .getSenderNameByMessageId(conversationId, messageId) + + return if (senderNameResult is GetSenderNameByMessageIdUseCase.Result.Success) senderNameResult.name else null + } + internal fun close() { audioMediaPlayer.reset() wavesMaskHelper.clear() } + + data class MessageIdWrapper(val conversationId: ConversationId, val messageId: String) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index d624f997f1f..b086bb7bdc4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -93,6 +93,7 @@ import com.wire.android.feature.sketch.model.DrawingCanvasNavArgs import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs import com.wire.android.mapper.MessageDateTimeGroup import com.wire.android.media.audiomessage.AudioSpeed +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand @@ -146,7 +147,6 @@ import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBack import com.wire.android.ui.home.conversations.messages.AudioMessagesState import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState -import com.wire.android.ui.home.conversations.messages.PlayingAudiMessage import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel import com.wire.android.ui.home.conversations.messages.item.MessageClickActions import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem @@ -1417,58 +1417,58 @@ fun JumpToLastMessageButton( @Composable fun BoxScope.JumpToPlayingAudioButton( lazyListState: LazyListState, - playingAudiMessage: PlayingAudiMessage?, + playingAudiMessage: PlayingAudioMessage, lazyPagingMessages: LazyPagingItems, modifier: Modifier = Modifier, coroutineScope: CoroutineScope = rememberCoroutineScope() ) { - val indexOfPlayedMessage = playingAudiMessage?.let { - lazyPagingMessages.itemSnapshotList + if (playingAudiMessage is PlayingAudioMessage.Some && playingAudiMessage.state.isPlaying()) { + val indexOfPlayedMessage = lazyPagingMessages.itemSnapshotList .indexOfFirst { playingAudiMessage.messageId == it?.header?.messageId } - } ?: -1 - if (indexOfPlayedMessage < 0) return + if (indexOfPlayedMessage < 0) return - val firstVisibleIndex = lazyListState.firstVisibleItemIndex - val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: firstVisibleIndex + val firstVisibleIndex = lazyListState.firstVisibleItemIndex + val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: firstVisibleIndex - if (indexOfPlayedMessage in firstVisibleIndex..lastVisibleIndex) return + if (indexOfPlayedMessage in firstVisibleIndex..lastVisibleIndex) return - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .wrapContentWidth() - .align(Alignment.TopCenter) - .padding(all = dimensions().spacing8x) - .clickable { coroutineScope.launch { lazyListState.animateScrollToItem(indexOfPlayedMessage) } } - .background( - color = colorsScheme().secondaryText, - shape = RoundedCornerShape(dimensions().corner16x) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .wrapContentWidth() + .align(Alignment.TopCenter) + .padding(all = dimensions().spacing8x) + .clickable { coroutineScope.launch { lazyListState.animateScrollToItem(indexOfPlayedMessage) } } + .background( + color = colorsScheme().secondaryText, + shape = RoundedCornerShape(dimensions().corner16x) + ) + .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x) + ) { + Icon( + modifier = Modifier.size(dimensions().systemMessageIconSize), + painter = painterResource(id = R.drawable.ic_play), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.onPrimaryButtonEnabled ) - .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x) - ) { - Icon( - modifier = Modifier.size(dimensions().systemMessageIconSize), - painter = painterResource(id = R.drawable.ic_play), - contentDescription = null, - tint = MaterialTheme.wireColorScheme.onPrimaryButtonEnabled - ) - Text( - modifier = Modifier - .padding(horizontal = dimensions().spacing8x) - .weight(1f, fill = false), - text = playingAudiMessage!!.authorName, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = colorsScheme().onPrimaryButtonEnabled, - style = MaterialTheme.wireTypography.body04, - ) - Text( - modifier = Modifier, - text = DateAndTimeParsers.audioMessageTime(playingAudiMessage.currentTimeMs.toLong()), - color = colorsScheme().onPrimaryButtonEnabled, - style = MaterialTheme.wireTypography.label03, - ) + Text( + modifier = Modifier + .padding(horizontal = dimensions().spacing8x) + .weight(1f, fill = false), + text = playingAudiMessage.authorName.asString(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.body04, + ) + Text( + modifier = Modifier, + text = DateAndTimeParsers.audioMessageTime(playingAudiMessage.state.currentPositionInMs.toLong()), + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.label03, + ) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index 5377c40f8c2..b2b025cb6d6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -28,8 +28,8 @@ import androidx.paging.PagingData import androidx.paging.map import com.wire.android.R import com.wire.android.appLogger -import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioSpeed +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.SavedStateViewModel @@ -65,7 +65,6 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase -import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase @@ -80,7 +79,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -106,18 +104,17 @@ class ConversationMessagesViewModel @Inject constructor( private val getMessageForConversation: GetMessagesForConversationUseCase, private val toggleReaction: ToggleReactionUseCase, private val resetSession: ResetSessionUseCase, - private val conversationAudioMessagePlayerProvider: ConversationAudioMessagePlayerProvider, + private val audioMessagePlayerProvider: ConversationAudioMessagePlayerProvider, private val getConversationUnreadEventsCount: GetConversationUnreadEventsCountUseCase, private val clearUsersTypingEvents: ClearUsersTypingEventsUseCase, private val getSearchedConversationMessagePosition: GetSearchedConversationMessagePositionUseCase, private val deleteMessage: DeleteMessageUseCase, - private val getSenderNameByMessageId: GetSenderNameByMessageIdUseCase ) : SavedStateViewModel(savedStateHandle) { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() val conversationId: QualifiedID = conversationNavArgs.conversationId private val searchedMessageIdNavArgs: String? = conversationNavArgs.searchedMessageId - private val conversationAudioMessagePlayer = conversationAudioMessagePlayerProvider.provide() + private val audioMessagePlayer = audioMessagePlayerProvider.provide() var conversationViewState by mutableStateOf( ConversationMessagesViewState( @@ -190,38 +187,15 @@ class ConversationMessagesViewModel @Inject constructor( } private fun observeAudioPlayerState() { - val observableAudioMessagesState = conversationAudioMessagePlayer.observableAudioMessagesState - .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) - - val playingMessageData = observableAudioMessagesState - .map { audioMessageStates -> - audioMessageStates.firstNotNullOfOrNull { (messageId, audioState) -> - if (audioState.audioMediaPlayingState == AudioMediaPlayingState.Playing) messageId else null - } - } - .distinctUntilChanged() - .map { messageId -> - val senderName = messageId?.let { - val result = getSenderNameByMessageId(conversationId, it) - if (result is GetSenderNameByMessageIdUseCase.Result.Success) result.name else null - } - - messageId to senderName - } + val observableAudioMessagesState = audioMessagePlayer.observableAudioMessagesState + .map { audioMessageStates -> audioMessageStates.mapKeys { it.key.messageId } } viewModelScope.launch { combine( observableAudioMessagesState, - conversationAudioMessagePlayer.audioSpeed, - playingMessageData - ) { audioMessageStates, audioSpeed, (playingMessageId, playingMessageSenderName) -> - val playingAudiMessage = playingMessageId?.let { - PlayingAudiMessage( - messageId = playingMessageId, - authorName = playingMessageSenderName.orEmpty(), - currentTimeMs = audioMessageStates[playingMessageId]?.currentPositionInMs ?: 0 - ) - } + audioMessagePlayer.audioSpeed, + audioMessagePlayer.playingAudioMessageFlow + ) { audioMessageStates, audioSpeed, playingAudiMessage -> AudioMessagesState(audioMessageStates.toPersistentMap(), audioSpeed, playingAudiMessage) } .collect { conversationViewState = conversationViewState.copy(audioMessagesState = it) } @@ -425,20 +399,18 @@ class ConversationMessagesViewModel @Inject constructor( } fun audioClick(messageId: String) { - viewModelScope.launch { - conversationAudioMessagePlayer.playAudio(conversationId, messageId) - } + audioMessagePlayer.playAudio(conversationId, messageId) } fun changeAudioPosition(messageId: String, position: Int) { viewModelScope.launch { - conversationAudioMessagePlayer.setPosition(messageId, position) + audioMessagePlayer.setPosition(conversationId, messageId, position) } } fun changeAudioSpeed(audioSpeed: AudioSpeed) { viewModelScope.launch { - conversationAudioMessagePlayer.setSpeed(audioSpeed) + audioMessagePlayer.setSpeed(audioSpeed) } } @@ -486,18 +458,13 @@ class ConversationMessagesViewModel @Inject constructor( it.map { message -> if (message.messageContent is UIMessageContent.AudioAssetMessage) { viewModelScope.launch { - conversationAudioMessagePlayer.fetchWavesMask(conversationId, message.header.messageId) + audioMessagePlayer.fetchWavesMask(conversationId, message.header.messageId) } } message } } - override fun onCleared() { - super.onCleared() - conversationAudioMessagePlayerProvider.onCleared() - } - private companion object { const val DEFAULT_ASSET_NAME = "Wire File" const val CURRENT_TIME_REFRESH_WINDOW_IN_MILLIS: Long = 60_000 diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt index 119139691d5..45ef36f5b4c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.home.conversations.messages import androidx.paging.PagingData import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.kalium.logic.data.message.MessageAssetStatus @@ -43,13 +44,7 @@ data class ConversationMessagesViewState( data class AudioMessagesState( val audioStates: PersistentMap = persistentMapOf(), val audioSpeed: AudioSpeed = AudioSpeed.NORMAL, - val playingAudiMessage: PlayingAudiMessage? = null -) - -data class PlayingAudiMessage( - val messageId: String, - val authorName: String, - val currentTimeMs: Int + val playingAudiMessage: PlayingAudioMessage = PlayingAudioMessage.None ) sealed class DownloadedAssetDialogVisibilityState { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index 63b0a150f9b..6dfd5498089 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -25,6 +25,7 @@ import androidx.paging.PagingData import androidx.paging.map import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toConversationItem +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents @@ -96,7 +97,8 @@ class GetConversationsFromSearchUseCase @Inject constructor( it.toConversationItem( userTypeMapper = userTypeMapper, searchQuery = searchQuery, - selfUserTeamId = observeSelfUser().firstOrNull()?.teamId + selfUserTeamId = observeSelfUser().firstOrNull()?.teamId, + playingAudioMessage = PlayingAudioMessage.None ) } }.flowOn(dispatchers.io()) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 070abed799e..2c24b41102e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -32,6 +32,7 @@ import com.wire.android.appLogger import com.wire.android.di.CurrentAccount import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toConversationItem +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider import com.wire.android.model.SnackBarMessage import com.wire.android.ui.common.bottomsheet.conversation.ConversationTypeDetail import com.wire.android.ui.common.dialogs.BlockUserDialogState @@ -114,6 +115,8 @@ interface ConversationListViewModel { fun muteConversation(conversationId: ConversationId?, mutedConversationStatus: MutedConversationStatus) {} fun moveConversationToFolder() {} fun searchQueryChanged(searchQuery: String) {} + fun playPauseCurrentAudio(conversationId: ConversationId, messageId: String) {} + fun stopCurrentAudio() {} } class ConversationListViewModelPreview( @@ -140,6 +143,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( private val refreshConversationsWithoutMetadata: RefreshConversationsWithoutMetadataUseCase, private val updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase, private val observeLegalHoldStateForSelfUser: ObserveLegalHoldStateForSelfUserUseCase, + private val audioMessagePlayerProvider: ConversationAudioMessagePlayerProvider, @CurrentAccount val currentAccount: UserId, private val userTypeMapper: UserTypeMapper, private val observeSelfUser: GetSelfUserUseCase @@ -153,6 +157,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( ): ConversationListViewModelImpl } + private val audioMessagePlayer = audioMessagePlayerProvider.provide() private val _infoMessage = MutableSharedFlow() override val infoMessage = _infoMessage.asSharedFlow() @@ -230,15 +235,20 @@ class ConversationListViewModelImpl @AssistedInject constructor( .onStart { emit("") } .distinctUntilChanged() .flatMapLatest { searchQuery: String -> - observeConversationListDetailsWithEvents( - fromArchive = conversationsSource == ConversationsSource.ARCHIVE, - conversationFilter = conversationsSource.toFilter() - ).combine(observeLegalHoldStateForSelfUser()) { conversations, selfUserLegalHoldStatus -> + combine( + observeConversationListDetailsWithEvents( + fromArchive = conversationsSource == ConversationsSource.ARCHIVE, + conversationFilter = conversationsSource.toFilter() + ), + observeLegalHoldStateForSelfUser(), + audioMessagePlayer.playingAudioMessageFlow + ) { conversations, selfUserLegalHoldStatus, playingAudioMessage -> conversations.map { conversationDetails -> conversationDetails.toConversationItem( userTypeMapper = userTypeMapper, searchQuery = searchQuery, - selfUserTeamId = observeSelfUser().firstOrNull()?.teamId + selfUserTeamId = observeSelfUser().firstOrNull()?.teamId, + playingAudioMessage = playingAudioMessage ).hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus) } to searchQuery } @@ -417,6 +427,14 @@ class ConversationListViewModelImpl @AssistedInject constructor( } } + override fun playPauseCurrentAudio(conversationId: ConversationId, messageId: String) { + audioMessagePlayer.resumeOrPauseCurrentlyPlayingAudioMessage(conversationId, messageId) + } + + override fun stopCurrentAudio() { + audioMessagePlayer.stopCurrentlyPlayingAudioMessage() + } + @Suppress("MultiLineIfElse") private suspend fun clearContentSnackbarResult( clearContentResult: ClearConversationContentUseCase.Result, @@ -448,7 +466,7 @@ private fun ConversationsSource.toFilter(): ConversationFilter = when (this) { } private fun ConversationItem.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus: LegalHoldStateForSelfUser) = - // if self user is under legal hold then we shouldn't show legal hold indicator next to every conversation +// if self user is under legal hold then we shouldn't show legal hold indicator next to every conversation // the indication is shown in the header of the conversation list for self user in that case and it's enough when (selfUserLegalHoldStatus) { is LegalHoldStateForSelfUser.Enabled -> when (this) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 3a180efd9a7..fcf2811f783 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -186,6 +186,16 @@ fun ConversationsScreenContent( } } + val onPlayPauseCurrentAudio: (conversationId: ConversationId, messageId: String) -> Unit = remember { + { conversationId, messageId -> + conversationListViewModel.playPauseCurrentAudio(conversationId, messageId) + } + } + + val onStopCurrentAudio: () -> Unit = remember { + { conversationListViewModel.stopCurrentAudio() } + } + when (val state = conversationListViewModel.conversationListState) { is ConversationListState.Paginated -> { val lazyPagingItems = state.conversations.collectAsLazyPagingItems() @@ -212,7 +222,10 @@ fun ConversationsScreenContent( R.string.call_permission_dialog_description ) ) - } + }, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio + ) // when there is no conversation in any folder searchBarState.isSearchActive -> SearchConversationsEmptyContent(onNewConversationClicked = onNewConversationClicked) @@ -239,7 +252,9 @@ fun ConversationsScreenContent( R.string.call_permission_dialog_description ) ) - } + }, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio ) // when there is no conversation in any folder searchBarState.isSearchActive -> SearchConversationsEmptyContent(onNewConversationClicked = onNewConversationClicked) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index d462bc90234..cd1fd53e8f0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -18,7 +18,9 @@ package com.wire.android.ui.home.conversationslist.common +import androidx.compose.foundation.Image import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -27,10 +29,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import com.wire.android.R @@ -49,9 +55,11 @@ import com.wire.android.ui.home.conversationslist.model.BadgeEventType import com.wire.android.ui.home.conversationslist.model.BlockingState import com.wire.android.ui.home.conversationslist.model.ConversationInfo import com.wire.android.ui.home.conversationslist.model.ConversationItem +import com.wire.android.ui.home.conversationslist.model.PlayingAudioInConversation import com.wire.android.ui.home.conversationslist.model.toUserInfoLabel import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.android.util.ui.toUIText @@ -72,7 +80,9 @@ fun ConversationItemFactory( openMenu: (ConversationItem) -> Unit = {}, openUserProfile: (UserId) -> Unit = {}, joinCall: (ConversationId) -> Unit = {}, - onAudioPermissionPermanentlyDenied: () -> Unit = {} + onAudioPermissionPermanentlyDenied: () -> Unit = {}, + onPlayPauseCurrentAudio: (conversationId: ConversationId, messageId: String) -> Unit = { _, _ -> }, + onStopCurrentAudio: () -> Unit = {} ) { val openConversationOptionDescription = stringResource(R.string.content_description_conversation_details_more_btn) val openUserProfileDescription = stringResource(R.string.content_description_open_user_profile_label) @@ -133,7 +143,9 @@ fun ConversationItemFactory( onJoinCallClick = { joinCall(conversation.conversationId) }, - onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied + onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio ) } @@ -148,7 +160,9 @@ private fun GeneralConversationItem( modifier: Modifier = Modifier, selectOnRadioGroup: () -> Unit = {}, subTitle: @Composable () -> Unit = {}, - onAudioPermissionPermanentlyDenied: () -> Unit + onAudioPermissionPermanentlyDenied: () -> Unit, + onPlayPauseCurrentAudio: (conversationId: ConversationId, messageId: String) -> Unit = { _, _ -> }, + onStopCurrentAudio: () -> Unit = {} ) { when (conversation) { is ConversationItem.GroupConversation -> { @@ -181,6 +195,12 @@ private fun GeneralConversationItem( buttonClick = onJoinCallClick, onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, ) + } else if (conversation.playingAudio != null) { + AudioControlButtons( + playingAudio = conversation.playingAudio!!, + onPlayPauseCurrentAudio = { onPlayPauseCurrentAudio(conversation.conversationId, it) }, + onStopCurrentAudio = onStopCurrentAudio + ) } else { Row( modifier = Modifier.padding(horizontal = dimensions().spacing8x), @@ -222,14 +242,22 @@ private fun GeneralConversationItem( clickable = onConversationItemClick, trailingIcon = { if (!isSelectable) { - Row( - modifier = Modifier.padding(horizontal = dimensions().spacing8x), - horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x) - ) { - if (mutedStatus != MutedConversationStatus.AllAllowed) { - MutedConversationBadge() + if (conversation.playingAudio != null) { + AudioControlButtons( + playingAudio = conversation.playingAudio!!, + onPlayPauseCurrentAudio = { onPlayPauseCurrentAudio(conversation.conversationId, it) }, + onStopCurrentAudio = onStopCurrentAudio + ) + } else { + Row( + modifier = Modifier.padding(horizontal = dimensions().spacing8x), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x) + ) { + if (mutedStatus != MutedConversationStatus.AllAllowed) { + MutedConversationBadge() + } + EventBadgeFactory(eventType = conversation.badgeEventType) } - EventBadgeFactory(eventType = conversation.badgeEventType) } } } @@ -262,6 +290,54 @@ private fun GeneralConversationItem( } } +@Composable +fun AudioControlButtons( + playingAudio: PlayingAudioInConversation, + modifier: Modifier = Modifier, + onPlayPauseCurrentAudio: (messageId: String) -> Unit = {}, + onStopCurrentAudio: () -> Unit = {} +) { + Row(modifier = modifier.padding(end = dimensions().spacing8x)) { + val playPauseIconId = if (playingAudio.isPaused) R.drawable.ic_play else R.drawable.ic_pause + + Image( + painter = painterResource(id = playPauseIconId), + contentDescription = null, + modifier = Modifier + .clickable { onPlayPauseCurrentAudio(playingAudio.messageId) } + .border( + width = dimensions().spacing1x, + shape = RoundedCornerShape( + topStart = dimensions().corner16x, + bottomStart = dimensions().corner16x + ), + color = colorsScheme().secondaryButtonDisabledOutline + ) + .size(dimensions().buttonSmallMinSize) + .padding(vertical = dimensions().spacing10x, horizontal = dimensions().spacing14x), + colorFilter = ColorFilter.tint(MaterialTheme.wireColorScheme.onSecondaryButtonEnabled) + ) + + Image( + painter = painterResource(id = R.drawable.ic_stop), + contentDescription = null, + modifier = Modifier + .clickable { onStopCurrentAudio() } + .border( + width = dimensions().spacing1x, + shape = RoundedCornerShape( + topEnd = dimensions().corner16x, + bottomEnd = dimensions().corner16x + ), + color = colorsScheme().secondaryButtonDisabledOutline + ) + .size(dimensions().buttonSmallMinSize) + .padding(vertical = dimensions().spacing10x, horizontal = dimensions().spacing14x), + colorFilter = ColorFilter.tint(MaterialTheme.wireColorScheme.onSecondaryButtonEnabled) + ) + } +} + @Composable fun LoadingConversationItem(modifier: Modifier = Modifier) { RowItemTemplate( @@ -322,7 +398,8 @@ fun PreviewGroupConversationItemWithUnreadCount() = WireTheme { isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -349,7 +426,8 @@ fun PreviewGroupConversationItemWithNoBadges() = WireTheme { isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -378,7 +456,8 @@ fun PreviewGroupConversationItemWithLastDeletedMessage() = WireTheme { isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -405,7 +484,8 @@ fun PreviewGroupConversationItemWithMutedBadgeAndUnreadMentionBadge() = WireThem isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -433,7 +513,8 @@ fun PreviewGroupConversationItemWithOngoingCall() = WireTheme { isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -517,7 +598,36 @@ fun PreviewPrivateConversationItemWithBlockedBadge() = WireTheme { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, - isUserDeleted = false + isUserDeleted = false, + playingAudio = null + ), + modifier = Modifier, + isSelectableItem = false, + isChecked = false, + {}, {}, {}, {}, {}, {} + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewPrivateConversationItemWithPlayingAudio() = WireTheme { + ConversationItemFactory( + conversation = ConversationItem.GroupConversation( + "groupName looooooooooooooooooooooooooooooooooooong", + conversationId = QualifiedID("value", "domain"), + mutedStatus = MutedConversationStatus.OnlyMentionsAndRepliesAllowed, + lastMessageContent = UILastMessageContent.TextMessage( + MessageBody(UIText.DynamicString("Very looooooooooooooooooooooong messageeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")) + ), + badgeEventType = BadgeEventType.UnreadMention, + selfMemberRole = null, + teamId = null, + isArchived = false, + isFromTheSameTeam = false, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false, + playingAudio = PlayingAudioInConversation("some_id", true) ), modifier = Modifier, isSelectableItem = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt index ed04d4d72bc..8f762d91b64 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt @@ -71,7 +71,9 @@ fun ConversationList( onOpenUserProfile: (UserId) -> Unit = {}, onJoinCall: (ConversationId) -> Unit = {}, onConversationSelectedOnRadioGroup: (ConversationItem) -> Unit = {}, - onAudioPermissionPermanentlyDenied: () -> Unit = {} + onAudioPermissionPermanentlyDenied: () -> Unit = {}, + onPlayPauseCurrentAudio: (conversationId: ConversationId, messageId: String) -> Unit = { _, _ -> }, + onStopCurrentAudio: () -> Unit = {} ) { val context = LocalContext.current @@ -121,6 +123,8 @@ fun ConversationList( openUserProfile = onOpenUserProfile, joinCall = onJoinCall, onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio ) else -> {} @@ -147,7 +151,9 @@ fun ConversationList( onOpenUserProfile: (UserId) -> Unit = {}, onJoinCall: (ConversationId) -> Unit = {}, onConversationSelectedOnRadioGroup: (ConversationId) -> Unit = {}, - onAudioPermissionPermanentlyDenied: () -> Unit = {} + onAudioPermissionPermanentlyDenied: () -> Unit = {}, + onPlayPauseCurrentAudio: (conversationId: ConversationId, messageId: String) -> Unit = { _, _ -> }, + onStopCurrentAudio: () -> Unit = {} ) { val context = LocalContext.current @@ -176,6 +182,8 @@ fun ConversationList( openUserProfile = onOpenUserProfile, joinCall = onJoinCall, onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio ) } } @@ -205,7 +213,8 @@ fun previewConversationList(count: Int, startIndex: Int = 0, unread: Boolean = f mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, searchQuery = searchQuery, - isFavorite = false + isFavorite = false, + playingAudio = null ) ) @@ -225,7 +234,8 @@ fun previewConversationList(count: Int, startIndex: Int = 0, unread: Boolean = f proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, searchQuery = searchQuery, isFavorite = false, - isUserDeleted = false + isUserDeleted = false, + playingAudio = null ) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index 66bced8c774..6152c473faa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -18,6 +18,7 @@ package com.wire.android.ui.home.conversationslist.model +import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.model.UserAvatarData import com.wire.android.ui.home.conversations.model.UILastMessageContent import com.wire.android.ui.home.conversationslist.common.UserInfoLabel @@ -45,6 +46,7 @@ sealed class ConversationItem : ConversationFolderItem { abstract val proteusVerificationStatus: Conversation.VerificationStatus abstract val hasNewActivitiesToShow: Boolean abstract val searchQuery: String + abstract val playingAudio: PlayingAudioInConversation? val isTeamConversation get() = teamId != null @@ -67,6 +69,7 @@ sealed class ConversationItem : ConversationFolderItem { override val proteusVerificationStatus: Conversation.VerificationStatus, override val hasNewActivitiesToShow: Boolean = false, override val searchQuery: String = "", + override val playingAudio: PlayingAudioInConversation? ) : ConversationItem() @Serializable @@ -88,6 +91,7 @@ sealed class ConversationItem : ConversationFolderItem { override val proteusVerificationStatus: Conversation.VerificationStatus, override val hasNewActivitiesToShow: Boolean = false, override val searchQuery: String = "", + override val playingAudio: PlayingAudioInConversation? ) : ConversationItem() @Serializable @@ -107,6 +111,7 @@ sealed class ConversationItem : ConversationFolderItem { override val teamId: TeamId? = null override val mlsVerificationStatus: Conversation.VerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED override val proteusVerificationStatus: Conversation.VerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + override val playingAudio: PlayingAudioInConversation? = null } } @@ -117,6 +122,12 @@ data class ConversationInfo( val isSenderUnavailable: Boolean = false ) +@Serializable +data class PlayingAudioInConversation( + val messageId: String, + val isPaused: Boolean +) + enum class BlockingState { CAN_NOT_BE_BLOCKED, // we should not be able to block our own team-members BLOCKED, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt index e6fc0b029ff..590eab6480b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt @@ -83,7 +83,7 @@ class RecordAudioViewModel @Inject constructor( fun setApplyEffectsAndPlayAudio(enabled: Boolean) { setShouldApplyEffects(enabled = enabled) - if (state.audioState.audioMediaPlayingState is AudioMediaPlayingState.Playing) { + if (state.audioState.isPlaying()) { onPlayAudio() } } diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index 4f6dc7d8b73..d63e97983fd 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -28,6 +28,7 @@ import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer.MessageIdWrapper import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.id.ConversationId @@ -40,6 +41,7 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import okio.Path import org.amshove.kluent.internal.assertEquals @@ -50,44 +52,46 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingAudioForFirstTime_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement() + val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) .withAudioMediaPlayerReturningTotalTime(1000) .withSuccessFullAssetFetch() .withCurrentSession() .arrange() val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs @@ -109,7 +113,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingTheSameMessageIdTwiceSequentially_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement() + val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) @@ -117,38 +121,40 @@ class ConversationAudioMessagePlayerTest { .arrange() val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() // playing first time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs @@ -158,11 +164,11 @@ class ConversationAudioMessagePlayerTest { // playing second time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Paused) } @@ -183,7 +189,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingDifferentAudioAfterFirstOneIsPlayed_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement() + val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) @@ -191,38 +197,41 @@ class ConversationAudioMessagePlayerTest { val firstAudioMessageId = "some-dummy-message-id1" val secondAudioMessageId = "some-dummy-message-id2" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val firstAudioMessageIdWrapper = MessageIdWrapper(conversationId, firstAudioMessageId) + val secondAudioMessageIdWrapper = MessageIdWrapper(conversationId, secondAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() // playing first audio message conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, firstAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs assert(totalTime is AudioState.TotalTimeInMs.Known) @@ -231,33 +240,33 @@ class ConversationAudioMessagePlayerTest { // playing second audio message conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, secondAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Stopped) } awaitAndAssertStateUpdate { state -> assert(state.size == 2) - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } @@ -277,7 +286,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingDifferentAudioAfterFirstOneIsPlayedAndSecondResumed_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement() + val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) @@ -285,38 +294,41 @@ class ConversationAudioMessagePlayerTest { val firstAudioMessageId = "some-dummy-message-id1" val secondAudioMessageId = "some-dummy-message-id2" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val firstAudioMessageIdWrapper = MessageIdWrapper(conversationId, firstAudioMessageId) + val secondAudioMessageIdWrapper = MessageIdWrapper(conversationId, secondAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() // playing first audio message conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, firstAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs @@ -331,35 +343,35 @@ class ConversationAudioMessagePlayerTest { ) awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Stopped) } awaitAndAssertStateUpdate { state -> assert(state.size == 2) - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs assert(totalTime is AudioState.TotalTimeInMs.Known) @@ -372,32 +384,32 @@ class ConversationAudioMessagePlayerTest { firstAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Stopped) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs @@ -419,7 +431,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingDifferentAudioAfterFirstOneIsPlayedAndSecondStoppedAndResume_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement() + val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) @@ -427,38 +439,40 @@ class ConversationAudioMessagePlayerTest { .arrange() val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() // playing first time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs assert(totalTime is AudioState.TotalTimeInMs.Known) @@ -467,11 +481,11 @@ class ConversationAudioMessagePlayerTest { // playing second time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Paused) } @@ -481,11 +495,11 @@ class ConversationAudioMessagePlayerTest { // playing third time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } @@ -506,7 +520,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenAudioSpeedChanged_thenMediaPlayerParamsWereUpdated() = runTest { val params = PlaybackParams() - val (arrangement, conversationAudioMessagePlayer) = Arrangement() + val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningParams(params) @@ -527,7 +541,7 @@ class ConversationAudioMessagePlayerTest { } } -class Arrangement { +class Arrangement(private val testScope: CoroutineScope) { @MockK lateinit var context: Context @@ -547,6 +561,7 @@ class Arrangement { mediaPlayer, wavesMaskHelper, coreLogic, + testScope ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index 01f5cb4d252..cee880ccbaf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -25,7 +25,9 @@ import com.wire.android.config.mockUri import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer.MessageIdWrapper import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage @@ -47,7 +49,6 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase -import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase @@ -117,9 +118,6 @@ class ConversationMessagesViewModelArrangement { @MockK lateinit var deleteMessage: DeleteMessageUseCase - @MockK - lateinit var getSenderNameByMessageId: GetSenderNameByMessageIdUseCase - private val viewModel: ConversationMessagesViewModel by lazy { ConversationMessagesViewModel( savedStateHandle, @@ -138,7 +136,6 @@ class ConversationMessagesViewModelArrangement { clearUsersTypingEvents, getSearchedConversationMessagePosition, deleteMessage, - getSenderNameByMessageId ) } @@ -163,7 +160,6 @@ class ConversationMessagesViewModelArrangement { coEvery { conversationAudioMessagePlayer.audioSpeed } returns flowOf(AudioSpeed.NORMAL) coEvery { conversationAudioMessagePlayer.fetchWavesMask(any(), any()) } returns Unit - coEvery { getSenderNameByMessageId(any(), any()) } returns GetSenderNameByMessageIdUseCase.Result.Success("User Name") } fun withSuccessfulViewModelInit() = apply { @@ -203,10 +199,14 @@ class ConversationMessagesViewModelArrangement { ) } - fun withObservableAudioMessagesState(audioFlow: Flow>) = apply { + fun withObservableAudioMessagesState(audioFlow: Flow>) = apply { coEvery { conversationAudioMessagePlayer.observableAudioMessagesState } returns audioFlow } + fun withPlayingAudioMessageFlow(playingAudioMessageFlow: Flow) = apply { + coEvery { conversationAudioMessagePlayer.playingAudioMessageFlow } returns playingAudioMessageFlow + } + suspend fun withPaginatedMessagesReturning(pagingDataFlow: PagingData) = apply { messagesChannel.send(pagingDataFlow) } @@ -237,9 +237,5 @@ class ConversationMessagesViewModelArrangement { return this } - fun withGetSenderNameByMessageId(result: GetSenderNameByMessageIdUseCase.Result) = apply { - coEvery { getSenderNameByMessageId(any(), any()) } returns result - } - fun arrange() = this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index 18f19efd891..47506c263bb 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -29,11 +29,14 @@ import com.wire.android.framework.TestMessage.GENERIC_ASSET_CONTENT import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer.MessageIdWrapper +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversations.ConversationSnackbarMessages import com.wire.android.ui.home.conversations.composer.mockUIAudioMessage import com.wire.android.ui.home.conversations.composer.mockUITextMessage import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogActiveState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogsState +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.data.message.MessageContent @@ -331,67 +334,32 @@ class ConversationMessagesViewModelTest { } @Test - fun `given an message ID, when some Audio is played, then should get message sender name by message ID`() = runTest { + fun `given an message ID, when some Audio is played, then state contains it`() = runTest { val message = TestMessage.ASSET_MESSAGE val audioState = AudioState.DEFAULT.copy( audioMediaPlayingState = AudioMediaPlayingState.Playing, totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), currentPositionInMs = 300 ) - val userName = "some name" - val expectedAudioMessagesState = AudioMessagesState( - audioStates = persistentMapOf(message.id to audioState), - audioSpeed = AudioSpeed.NORMAL, - playingAudiMessage = PlayingAudiMessage( - messageId = message.id, - authorName = userName, - currentTimeMs = audioState.currentPositionInMs - ) - ) - val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withSuccessfulViewModelInit() - .withGetSenderNameByMessageId(GetSenderNameByMessageIdUseCase.Result.Success(userName)) - .withObservableAudioMessagesState( - flowOf( - mapOf( - message.id to audioState.copy(currentPositionInMs = 100), - message.id to audioState - ) - ) - ) - .arrange() - - advanceUntilIdle() - - coVerify(exactly = 1) { arrangement.getSenderNameByMessageId(arrangement.conversationId, message.id) } - assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) - } - - @Test - fun `given an message ID, when getSenderNameByMessageId fails, then senderName in PlayingAudiMessage is empty`() = runTest { - val message = TestMessage.ASSET_MESSAGE - val audioState = AudioState.DEFAULT.copy( - audioMediaPlayingState = AudioMediaPlayingState.Playing, - totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), - currentPositionInMs = 300 + val playingAudiMessage = PlayingAudioMessage.Some( + conversationId = message.conversationId, + messageId = message.id, + authorName = UIText.DynamicString("some name"), + state = AudioState.DEFAULT.copy(currentPositionInMs = audioState.currentPositionInMs) ) val expectedAudioMessagesState = AudioMessagesState( audioStates = persistentMapOf(message.id to audioState), audioSpeed = AudioSpeed.NORMAL, - playingAudiMessage = PlayingAudiMessage( - messageId = message.id, - authorName = "", - currentTimeMs = audioState.currentPositionInMs - ) + playingAudiMessage = playingAudiMessage ) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() .withSuccessfulViewModelInit() - .withGetSenderNameByMessageId(GetSenderNameByMessageIdUseCase.Result.Failure(CoreFailure.Unknown(null))) + .withPlayingAudioMessageFlow(flowOf(playingAudiMessage)) .withObservableAudioMessagesState( flowOf( mapOf( - message.id to audioState.copy(currentPositionInMs = 100), - message.id to audioState + MessageIdWrapper(message.conversationId, message.id) to audioState.copy(currentPositionInMs = 100), + MessageIdWrapper(message.conversationId, message.id) to audioState ) ) ) @@ -403,7 +371,7 @@ class ConversationMessagesViewModelTest { } @Test - fun `given an message ID, when no playing Audio message, then PlayingAudiMessage is null`() = runTest { + fun `given an message ID, when no playing Audio message, then PlayingAudioMessage is None`() = runTest { val message = TestMessage.ASSET_MESSAGE val audioState = AudioState.DEFAULT.copy( audioMediaPlayingState = AudioMediaPlayingState.Stopped, @@ -413,16 +381,16 @@ class ConversationMessagesViewModelTest { val expectedAudioMessagesState = AudioMessagesState( audioStates = persistentMapOf(message.id to audioState), audioSpeed = AudioSpeed.NORMAL, - playingAudiMessage = null + playingAudiMessage = PlayingAudioMessage.None ) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() .withSuccessfulViewModelInit() - .withObservableAudioMessagesState(flowOf(mapOf(message.id to audioState))) + .withPlayingAudioMessageFlow(flowOf(PlayingAudioMessage.None)) + .withObservableAudioMessagesState(flowOf(mapOf(MessageIdWrapper(message.conversationId, message.id) to audioState))) .arrange() advanceUntilIdle() - coVerify(exactly = 0) { arrangement.getSenderNameByMessageId(arrangement.conversationId, message.id) } assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index 56bc7e134b5..3caf91db18c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -31,6 +31,9 @@ import com.wire.android.framework.TestConversationDetails import com.wire.android.framework.TestConversationItem import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.common.dialogs.BlockUserDialogState import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase import com.wire.android.ui.home.conversationslist.model.ConversationItem @@ -59,6 +62,7 @@ import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -274,6 +278,12 @@ class ConversationListViewModelTest { @MockK private lateinit var observeSelfUser: GetSelfUserUseCase + @MockK + lateinit var audioMessagePlayer: ConversationAudioMessagePlayer + + @MockK + lateinit var audioMessagePlayerProvider: ConversationAudioMessagePlayerProvider + init { MockKAnnotations.init(this, relaxUnitFun = true) withConversationsPaginated(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) @@ -289,6 +299,9 @@ class ConversationListViewModelTest { ) } ) + every { audioMessagePlayerProvider.provide() } returns audioMessagePlayer + every { audioMessagePlayerProvider.onCleared() } returns Unit + every { audioMessagePlayer.playingAudioMessageFlow } returns flowOf(PlayingAudioMessage.None) mockUri() } @@ -343,7 +356,8 @@ class ConversationListViewModelTest { observeLegalHoldStateForSelfUser = observeLegalHoldStateForSelfUserUseCase, userTypeMapper = UserTypeMapper(), observeSelfUser = observeSelfUser, - usePagination = true + usePagination = true, + audioMessagePlayerProvider = audioMessagePlayerProvider ) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index 81fc570b382..ee2b981dec1 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -141,6 +141,7 @@ data class WireDimensions( val spacing8x: Dp, val spacing10x: Dp, val spacing12x: Dp, + val spacing14x: Dp, val spacing16x: Dp, val spacing18x: Dp, val spacing20x: Dp, @@ -302,6 +303,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( spacing8x = 8.dp, spacing10x = 10.dp, spacing12x = 12.dp, + spacing14x = 14.dp, spacing16x = 16.dp, spacing18x = 18.dp, spacing20x = 20.dp, diff --git a/kalium b/kalium index b4b66eb70a5..dea30b6d57a 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit b4b66eb70a59f7f1c8178d75498d14a2cc4a0db8 +Subproject commit dea30b6d57aec0072cf20c31fc2bd5dc233b3f60 From 8711849c8b673662c7d4a21e780996f6e3b59f05 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Mon, 23 Dec 2024 16:06:15 +0200 Subject: [PATCH 14/19] Updated kalium --- .../common/ConversationItemFactory.kt | 25 +++++++++++-------- kalium | 2 +- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index cd1fd53e8f0..d4789ac6b58 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics import com.wire.android.R import com.wire.android.model.Clickable @@ -300,17 +301,20 @@ fun AudioControlButtons( Row(modifier = modifier.padding(end = dimensions().spacing8x)) { val playPauseIconId = if (playingAudio.isPaused) R.drawable.ic_play else R.drawable.ic_pause + val leftBtnShape = RoundedCornerShape(topStart = dimensions().corner16x, bottomStart = dimensions().corner16x) + val rightBtnShape = RoundedCornerShape(topEnd = dimensions().corner16x, bottomEnd = dimensions().corner16x) Image( painter = painterResource(id = playPauseIconId), contentDescription = null, modifier = Modifier - .clickable { onPlayPauseCurrentAudio(playingAudio.messageId) } + .clip(leftBtnShape) + .clickable( + onClickLabel = "", // TODO + role = Role.Button + ) { onPlayPauseCurrentAudio(playingAudio.messageId) } .border( width = dimensions().spacing1x, - shape = RoundedCornerShape( - topStart = dimensions().corner16x, - bottomStart = dimensions().corner16x - ), + shape = leftBtnShape, color = colorsScheme().secondaryButtonDisabledOutline ) .size(dimensions().buttonSmallMinSize) @@ -322,13 +326,14 @@ fun AudioControlButtons( painter = painterResource(id = R.drawable.ic_stop), contentDescription = null, modifier = Modifier - .clickable { onStopCurrentAudio() } + .clip(rightBtnShape) + .clickable( + onClickLabel = "", // TODO + role = Role.Button + ) { onStopCurrentAudio() } .border( width = dimensions().spacing1x, - shape = RoundedCornerShape( - topEnd = dimensions().corner16x, - bottomEnd = dimensions().corner16x - ), + shape = rightBtnShape, color = colorsScheme().secondaryButtonDisabledOutline ) .size(dimensions().buttonSmallMinSize) diff --git a/kalium b/kalium index dea30b6d57a..ecdca03d0db 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit dea30b6d57aec0072cf20c31fc2bd5dc233b3f60 +Subproject commit ecdca03d0dbbfb07f8596c10b490e84f27513f43 From 5644f0adf4b59bbc75dfeb462b977d1308f5c0b9 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Mon, 23 Dec 2024 17:24:48 +0200 Subject: [PATCH 15/19] Added play control buttons for paginated list too --- .../usecase/GetConversationsFromSearchUseCase.kt | 5 +++-- .../conversationslist/ConversationListViewModel.kt | 11 +++++------ .../common/ConversationItemFactory.kt | 14 ++++---------- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index 6dfd5498089..a9e25459fc6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -55,7 +55,8 @@ class GetConversationsFromSearchUseCase @Inject constructor( fromArchive: Boolean = false, newActivitiesOnTop: Boolean = false, onlyInteractionEnabled: Boolean = false, - conversationFilter: ConversationFilter = ConversationFilter.All + conversationFilter: ConversationFilter = ConversationFilter.All, + playingAudioMessage: PlayingAudioMessage = PlayingAudioMessage.None ): Flow> { val pagingConfig = PagingConfig( pageSize = PAGE_SIZE, @@ -98,7 +99,7 @@ class GetConversationsFromSearchUseCase @Inject constructor( userTypeMapper = userTypeMapper, searchQuery = searchQuery, selfUserTeamId = observeSelfUser().firstOrNull()?.teamId, - playingAudioMessage = PlayingAudioMessage.None + playingAudioMessage = playingAudioMessage ) } }.flowOn(dispatchers.io()) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 2c24b41102e..6bab10590c2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -72,6 +72,7 @@ import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCa import com.wire.kalium.logic.feature.team.DeleteTeamConversationUseCase import com.wire.kalium.logic.feature.team.Result import com.wire.kalium.logic.feature.user.GetSelfUserUseCase +import com.wire.kalium.logic.functional.combine import com.wire.kalium.util.DateTimeUtil import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -182,18 +183,16 @@ class ConversationListViewModelImpl @AssistedInject constructor( .debounce { if (it.isEmpty()) 0L else DEFAULT_SEARCH_QUERY_DEBOUNCE } .onStart { emit("") } .distinctUntilChanged() - .flatMapLatest { searchQuery -> + .combine(audioMessagePlayer.playingAudioMessageFlow) + .flatMapLatest { (searchQuery, playingAudioMessage) -> getConversationsPaginated( searchQuery = searchQuery, fromArchive = conversationsSource == ConversationsSource.ARCHIVE, conversationFilter = conversationsSource.toFilter(), onlyInteractionEnabled = false, newActivitiesOnTop = containsNewActivitiesSection, - ).combine(observeLegalHoldStateForSelfUser()) { conversations, selfUserLegalHoldStatus -> - conversations.map { - it.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus) - } - }.map { + playingAudioMessage = playingAudioMessage + ).map { it.insertSeparators { before, after -> when { // do not add separators if the list shouldn't show conversations grouped into different folders diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index d4789ac6b58..8524aad59ec 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -305,13 +305,10 @@ fun AudioControlButtons( val rightBtnShape = RoundedCornerShape(topEnd = dimensions().corner16x, bottomEnd = dimensions().corner16x) Image( painter = painterResource(id = playPauseIconId), - contentDescription = null, + contentDescription = null, // TODO modifier = Modifier .clip(leftBtnShape) - .clickable( - onClickLabel = "", // TODO - role = Role.Button - ) { onPlayPauseCurrentAudio(playingAudio.messageId) } + .clickable(role = Role.Button) { onPlayPauseCurrentAudio(playingAudio.messageId) } .border( width = dimensions().spacing1x, shape = leftBtnShape, @@ -324,13 +321,10 @@ fun AudioControlButtons( Image( painter = painterResource(id = R.drawable.ic_stop), - contentDescription = null, + contentDescription = null, // TODO modifier = Modifier .clip(rightBtnShape) - .clickable( - onClickLabel = "", // TODO - role = Role.Button - ) { onStopCurrentAudio() } + .clickable(role = Role.Button) { onStopCurrentAudio() } .border( width = dimensions().spacing1x, shape = rightBtnShape, From d0d0ffba2a6095fae7155a7877c93d858ac361a9 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Fri, 27 Dec 2024 16:25:34 +0200 Subject: [PATCH 16/19] Fix code-style --- .../media/audiomessage/ConversationAudioMessagePlayer.kt | 3 ++- .../conversations/messages/ConversationMessagesViewModel.kt | 1 - .../usecase/GetConversationsFromSearchUseCase.kt | 1 + .../ui/home/conversationslist/ConversationsScreenContent.kt | 6 ++++-- .../conversationslist/common/ConversationItemFactory.kt | 1 + .../ui/home/conversationslist/model/ConversationItem.kt | 1 - .../messages/ConversationMessagesViewModelTest.kt | 2 -- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index b70cdb031b6..849d282f060 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -173,7 +173,8 @@ internal constructor( // The audio message position can be either updated by the user manually by for example a Slider component or by the player itself. val observableAudioMessagesState: Flow> = merge(positionChangedUpdate, audioMessageStateUpdate).map { audioMessageStateUpdate -> - val messageIdKey = MessageIdWrapper(audioMessageStateUpdate.conversationId, audioMessageStateUpdate.messageId) + val messageIdKey = + MessageIdWrapper(audioMessageStateUpdate.conversationId, audioMessageStateUpdate.messageId) val currentState = audioMessageStateHistory.getOrDefault( messageIdKey, AudioState.DEFAULT diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index b2b025cb6d6..85ebd954467 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -29,7 +29,6 @@ import androidx.paging.map import com.wire.android.R import com.wire.android.appLogger import com.wire.android.media.audiomessage.AudioSpeed -import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.SavedStateViewModel diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index a9e25459fc6..ebb9f714ac5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -50,6 +50,7 @@ class GetConversationsFromSearchUseCase @Inject constructor( private val dispatchers: DispatcherProvider, private val observeSelfUser: GetSelfUserUseCase ) { + @Suppress("LongParameterList") suspend operator fun invoke( searchQuery: String = "", fromArchive: Boolean = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index fcf2811f783..285dd1400eb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -79,7 +79,7 @@ import com.wire.kalium.logic.data.user.UserId * This is a base for creating screens for displaying list of conversations. * Can be used to create proper navigation destination for different sources of conversations, like archive. */ -@Suppress("ComplexMethod", "NestedBlockDepth") +@Suppress("ComplexMethod", "NestedBlockDepth", "Wrapping") @Composable fun ConversationsScreenContent( navigator: Navigator, @@ -193,7 +193,9 @@ fun ConversationsScreenContent( } val onStopCurrentAudio: () -> Unit = remember { - { conversationListViewModel.stopCurrentAudio() } + { + conversationListViewModel.stopCurrentAudio() + } } when (val state = conversationListViewModel.conversationListState) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index 8524aad59ec..7d4187d2f3b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -15,6 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("TooManyFunctions") package com.wire.android.ui.home.conversationslist.common diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index 6152c473faa..8c4e0763a50 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.conversationslist.model -import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.model.UserAvatarData import com.wire.android.ui.home.conversations.model.UILastMessageContent import com.wire.android.ui.home.conversationslist.common.UserInfoLabel diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index 47506c263bb..1df6eecd8ed 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -37,12 +37,10 @@ import com.wire.android.ui.home.conversations.composer.mockUITextMessage import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogActiveState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogsState import com.wire.android.util.ui.UIText -import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.data.message.MessageContent import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.GetConversationUnreadEventsCountUseCase -import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import io.mockk.coVerify import io.mockk.verify import kotlinx.collections.immutable.persistentMapOf From f8dafe21ef6a7adedfed8050099222969a33302d Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Thu, 2 Jan 2025 14:42:06 +0200 Subject: [PATCH 17/19] Fixed some tests --- .../wire/android/framework/TestConversationItem.kt | 6 ++++-- .../media/ConversationAudioMessagePlayerTest.kt | 13 ++++++++++++- .../ConversationMessagesViewModelArrangement.kt | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt index 848fb683703..7ceb5b27dd9 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt @@ -46,7 +46,8 @@ object TestConversationItem { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, - isUserDeleted = false + isUserDeleted = false, + playingAudio = null ) val GROUP = ConversationItem.GroupConversation( @@ -63,7 +64,8 @@ object TestConversationItem { isArchived = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + playingAudio = null ) val CONNECTION = ConversationItem.ConnectionConversation( diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index d63e97983fd..5912888ae2d 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -42,6 +42,7 @@ import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.Path import org.amshove.kluent.internal.assertEquals @@ -69,6 +70,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] @@ -132,6 +134,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] @@ -167,6 +170,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) @@ -209,6 +213,7 @@ class ConversationAudioMessagePlayerTest { conversationId, firstAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] @@ -306,6 +311,7 @@ class ConversationAudioMessagePlayerTest { conversationId, firstAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] @@ -341,6 +347,7 @@ class ConversationAudioMessagePlayerTest { ConversationId("some-dummy-value", "some.dummy.domain"), secondAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] @@ -383,6 +390,7 @@ class ConversationAudioMessagePlayerTest { ConversationId("some-dummy-value", "some.dummy.domain"), firstAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) @@ -450,6 +458,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] @@ -484,6 +493,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) @@ -498,6 +508,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) @@ -520,7 +531,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenAudioSpeedChanged_thenMediaPlayerParamsWereUpdated() = runTest { val params = PlaybackParams() - val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) + val (arrangement, conversationAudioMessagePlayer) = Arrangement(this.backgroundScope) .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningParams(params) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index cee880ccbaf..1204e6fd988 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -160,6 +160,7 @@ class ConversationMessagesViewModelArrangement { coEvery { conversationAudioMessagePlayer.audioSpeed } returns flowOf(AudioSpeed.NORMAL) coEvery { conversationAudioMessagePlayer.fetchWavesMask(any(), any()) } returns Unit + coEvery { conversationAudioMessagePlayer.playingAudioMessageFlow } returns flowOf(PlayingAudioMessage.None) } fun withSuccessfulViewModelInit() = apply { From caa70fc03387c54240f2b0a589a23ae66a600b7e Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Fri, 3 Jan 2025 11:45:14 +0200 Subject: [PATCH 18/19] Fixed tests --- .../ConversationAudioMessagePlayer.kt | 114 +++++++++--------- .../messages/ConversationMessagesViewModel.kt | 4 +- .../ConversationListViewModel.kt | 52 ++++---- .../ConversationAudioMessagePlayerTest.kt | 36 +++--- 4 files changed, 107 insertions(+), 99 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index 849d282f060..f894a7a0d64 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -263,7 +263,7 @@ internal constructor( val audioSpeed: Flow = _audioSpeed.onStart { emit(AudioSpeed.NORMAL) } - fun playAudio( + suspend fun playAudio( conversationId: ConversationId, requestedAudioMessageId: String ) { @@ -296,98 +296,92 @@ internal constructor( } } - fun stopCurrentlyPlayingAudioMessage() { - scope.launch { - currentAudioMessageId?.let { - val currentAudioState = audioMessageStateHistory[it] - if (currentAudioState?.audioMediaPlayingState != AudioMediaPlayingState.Fetching) { - stop(it.conversationId, it.messageId) - } + suspend fun stopCurrentlyPlayingAudioMessage() { + currentAudioMessageId?.let { + val currentAudioState = audioMessageStateHistory[it] + if (currentAudioState?.audioMediaPlayingState != AudioMediaPlayingState.Fetching) { + stop(it.conversationId, it.messageId) } } } - fun resumeOrPauseCurrentlyPlayingAudioMessage(conversationId: ConversationId, messageId: String) { - scope.launch { - if (audioMediaPlayer.isPlaying) { - pause(conversationId, messageId) - } else { - resumeAudio(conversationId, messageId) - } + suspend fun resumeOrPauseCurrentlyPlayingAudioMessage(conversationId: ConversationId, messageId: String) { + if (audioMediaPlayer.isPlaying) { + pause(conversationId, messageId) + } else { + resumeAudio(conversationId, messageId) } } - private fun playAudioMessage( + private suspend fun playAudioMessage( conversationId: ConversationId, messageId: String, position: Int? = null ) { currentAudioMessageId = MessageIdWrapper(conversationId, messageId) - scope.launch { - val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() - if (currentAccountResult is CurrentSessionResult.Failure) return@launch + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Failure) return - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Fetching) - ) + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Fetching) + ) - val assetMessage = getAssetMessage(currentAccountResult, conversationId, messageId) + val assetMessage = getAssetMessage(currentAccountResult, conversationId, messageId) - when (val result = assetMessage.await()) { - is MessageAssetResult.Success -> { - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - conversationId, - messageId, - AudioMediaPlayingState.SuccessfulFetching - ) + when (val result = assetMessage.await()) { + is MessageAssetResult.Success -> { + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( + conversationId, + messageId, + AudioMediaPlayingState.SuccessfulFetching ) + ) - val isFetchedAudioCurrentlyQueuedToPlay = MessageIdWrapper(conversationId, messageId) == currentAudioMessageId + val isFetchedAudioCurrentlyQueuedToPlay = MessageIdWrapper(conversationId, messageId) == currentAudioMessageId - if (isFetchedAudioCurrentlyQueuedToPlay) { - audioMediaPlayer.setDataSource(context, Uri.parse(result.decodedAssetPath.toString())) - audioMediaPlayer.prepare() + if (isFetchedAudioCurrentlyQueuedToPlay) { + audioMediaPlayer.setDataSource(context, Uri.parse(result.decodedAssetPath.toString())) + audioMediaPlayer.prepare() - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.WaveMaskUpdate( - conversationId, - messageId, - wavesMaskHelper.getWaveMask(result.decodedAssetPath) - ) + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.WaveMaskUpdate( + conversationId, + messageId, + wavesMaskHelper.getWaveMask(result.decodedAssetPath) ) + ) - if (position != null) audioMediaPlayer.seekTo(position) - - audioMediaPlayer.start() + if (position != null) audioMediaPlayer.seekTo(position) - updateSpeedFlow() + audioMediaPlayer.start() - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - conversationId, - messageId, - AudioMediaPlayingState.Playing - ) - ) - - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.TotalTimeUpdate(conversationId, messageId, audioMediaPlayer.duration) - ) - } - } + updateSpeedFlow() - is MessageAssetResult.Failure -> { audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( conversationId, messageId, - AudioMediaPlayingState.Failed + AudioMediaPlayingState.Playing ) ) + + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.TotalTimeUpdate(conversationId, messageId, audioMediaPlayer.duration) + ) } } + + is MessageAssetResult.Failure -> { + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( + conversationId, + messageId, + AudioMediaPlayingState.Failed + ) + ) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index 85ebd954467..8aa1eaff01e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -398,7 +398,9 @@ class ConversationMessagesViewModel @Inject constructor( } fun audioClick(messageId: String) { - audioMessagePlayer.playAudio(conversationId, messageId) + viewModelScope.launch { + audioMessagePlayer.playAudio(conversationId, messageId) + } } fun changeAudioPosition(messageId: String, position: Int) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 4c22d621957..e50d0362b9d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -201,28 +201,30 @@ class ConversationListViewModelImpl @AssistedInject constructor( onlyInteractionEnabled = false, newActivitiesOnTop = containsNewActivitiesSection, playingAudioMessage = playingAudioMessage - ).map { - it.insertSeparators { before, after -> - when { - // do not add separators if the list shouldn't show conversations grouped into different folders - !containsNewActivitiesSection -> null - - before == null && after != null && after.hasNewActivitiesToShow -> - // list starts with items with "new activities" - ConversationFolder.Predefined.NewActivities - - before == null && after != null && !after.hasNewActivitiesToShow -> - // list doesn't contain any items with "new activities" - ConversationFolder.Predefined.Conversations - - before != null && before.hasNewActivitiesToShow && after != null && !after.hasNewActivitiesToShow -> - // end of "new activities" section and beginning of "conversations" section - ConversationFolder.Predefined.Conversations - - else -> null - } + ).combine(observeLegalHoldStateForSelfUser()) + .map { (conversations, selfUserLegalHoldStatus) -> + conversations.map { it.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus) } + .insertSeparators { before, after -> + when { + // do not add separators if the list shouldn't show conversations grouped into different folders + !containsNewActivitiesSection -> null + + before == null && after != null && after.hasNewActivitiesToShow -> + // list starts with items with "new activities" + ConversationFolder.Predefined.NewActivities + + before == null && after != null && !after.hasNewActivitiesToShow -> + // list doesn't contain any items with "new activities" + ConversationFolder.Predefined.Conversations + + before != null && before.hasNewActivitiesToShow && after != null && !after.hasNewActivitiesToShow -> + // end of "new activities" section and beginning of "conversations" section + ConversationFolder.Predefined.Conversations + + else -> null + } + } } - } } .flowOn(dispatcher.io()) .cachedIn(viewModelScope) @@ -465,11 +467,15 @@ class ConversationListViewModelImpl @AssistedInject constructor( } override fun playPauseCurrentAudio(conversationId: ConversationId, messageId: String) { - audioMessagePlayer.resumeOrPauseCurrentlyPlayingAudioMessage(conversationId, messageId) + viewModelScope.launch { + audioMessagePlayer.resumeOrPauseCurrentlyPlayingAudioMessage(conversationId, messageId) + } } override fun stopCurrentAudio() { - audioMessagePlayer.stopCurrentlyPlayingAudioMessage() + viewModelScope.launch { + audioMessagePlayer.stopCurrentlyPlayingAudioMessage() + } } @Suppress("MultiLineIfElse") diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index 5912888ae2d..8561606bca5 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -42,6 +42,7 @@ import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.Path @@ -53,7 +54,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingAudioForFirstTime_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) + val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withAudioMediaPlayerReturningTotalTime(1000) .withSuccessFullAssetFetch() .withCurrentSession() @@ -70,7 +71,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) - advanceUntilIdle() + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] @@ -115,7 +116,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingTheSameMessageIdTwiceSequentially_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) + val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) @@ -156,6 +157,7 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } + awaitItem() // currentPosition update awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) @@ -193,7 +195,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingDifferentAudioAfterFirstOneIsPlayed_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) + val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) @@ -213,7 +215,7 @@ class ConversationAudioMessagePlayerTest { conversationId, firstAudioMessageId ) - advanceUntilIdle() + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] @@ -291,7 +293,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingDifferentAudioAfterFirstOneIsPlayedAndSecondResumed_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) + val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) @@ -311,7 +313,7 @@ class ConversationAudioMessagePlayerTest { conversationId, firstAudioMessageId ) - advanceUntilIdle() + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] @@ -347,7 +349,7 @@ class ConversationAudioMessagePlayerTest { ConversationId("some-dummy-value", "some.dummy.domain"), secondAudioMessageId ) - advanceUntilIdle() + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] @@ -390,7 +392,7 @@ class ConversationAudioMessagePlayerTest { ConversationId("some-dummy-value", "some.dummy.domain"), firstAudioMessageId ) - advanceUntilIdle() + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) @@ -439,7 +441,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenPlayingDifferentAudioAfterFirstOneIsPlayedAndSecondStoppedAndResume_thenEmitStatesAsExpected() = runTest { - val (arrangement, conversationAudioMessagePlayer) = Arrangement(this) + val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) @@ -458,7 +460,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) - advanceUntilIdle() + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] @@ -480,6 +482,7 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } + awaitItem() // currentPosition update awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) @@ -493,7 +496,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) - advanceUntilIdle() + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) @@ -508,7 +511,7 @@ class ConversationAudioMessagePlayerTest { conversationId, testAudioMessageId ) - advanceUntilIdle() + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) @@ -531,7 +534,7 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessFullAssetFetch_whenAudioSpeedChanged_thenMediaPlayerParamsWereUpdated() = runTest { val params = PlaybackParams() - val (arrangement, conversationAudioMessagePlayer) = Arrangement(this.backgroundScope) + val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withSuccessFullAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningParams(params) @@ -552,7 +555,7 @@ class ConversationAudioMessagePlayerTest { } } -class Arrangement(private val testScope: CoroutineScope) { +class Arrangement() { @MockK lateinit var context: Context @@ -566,6 +569,8 @@ class Arrangement(private val testScope: CoroutineScope) { @MockK lateinit var wavesMaskHelper: AudioWavesMaskHelper + private val testScope = CoroutineScope(UnconfinedTestDispatcher()) + private val conversationAudioMessagePlayer by lazy { ConversationAudioMessagePlayer( context, @@ -581,6 +586,7 @@ class Arrangement(private val testScope: CoroutineScope) { every { wavesMaskHelper.getWaveMask(any()) } returns WAVES_MASK every { wavesMaskHelper.clear() } returns Unit + every { mediaPlayer.currentPosition } returns 100 } fun withCurrentSession() = apply { From 81febf8d72228bb5c12b4bf6efae285bf2440272 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Fri, 3 Jan 2025 11:54:41 +0200 Subject: [PATCH 19/19] Code style fix --- .../ui/home/conversationslist/ConversationListViewModel.kt | 2 ++ .../wire/android/media/ConversationAudioMessagePlayerTest.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index e50d0362b9d..5959f5dee9f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -101,6 +101,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import java.util.Date +@Suppress("TooManyFunctions") interface ConversationListViewModel { val infoMessage: SharedFlow get() = MutableSharedFlow() val closeBottomSheet: SharedFlow get() = MutableSharedFlow() @@ -127,6 +128,7 @@ interface ConversationListViewModel { fun stopCurrentAudio() {} } +@Suppress("TooManyFunctions") class ConversationListViewModelPreview( foldersWithConversations: Flow> = previewConversationFoldersFlow(), ) : ConversationListViewModel { diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index 8561606bca5..46b9d0d84aa 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -555,7 +555,7 @@ class ConversationAudioMessagePlayerTest { } } -class Arrangement() { +class Arrangement { @MockK lateinit var context: Context