From 88a706fac0beeda40e31215ebdd35cfde49b6c98 Mon Sep 17 00:00:00 2001 From: Boris Safonov Date: Fri, 29 Nov 2024 11:48:18 +0200 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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