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/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index 2c1f3dd1692..a28486b38b7 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 @@ -35,6 +35,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 @@ -222,4 +223,9 @@ class MessageModule { @Provides fun provideSendInCallReactionUseCase(messageScope: MessageScope): SendInCallReactionUseCase = messageScope.sendInCallReactionUseCase + + @ViewModelScoped + @Provides + fun provideGetSenderNameByMessageIdUseCase(messageScope: MessageScope): GetSenderNameByMessageIdUseCase = + messageScope.getSenderNameByMessageId } 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..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 @@ -17,20 +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 totalTimeInMs: TotalTimeInMs, + 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 @@ -43,6 +47,27 @@ 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), + 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 < FAST.value) -> NORMAL + (speed < MAX.value) -> FAST + else -> MAX + } + } +} + sealed class AudioMediaPlayingState { object Playing : AudioMediaPlayingState() object Stopped : AudioMediaPlayingState() @@ -75,6 +100,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 { @@ -89,4 +119,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..c4bc1df831c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt @@ -0,0 +1,76 @@ +/* + * 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() / WAVES_AMOUNT).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 { + if (isEmpty()) return 0.0 + return sum().toDouble() / size + } + + 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 3f6c08ed1be..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 @@ -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,9 @@ 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 @@ -69,14 +72,16 @@ class ConversationAudioMessagePlayerProvider } } +@Suppress("TooManyFunctions") class ConversationAudioMessagePlayer internal constructor( private val context: Context, private val audioMediaPlayer: MediaPlayer, + private val wavesMaskHelper: AudioWavesMaskHelper, @KaliumCoreLogic private val coreLogic: CoreLogic, ) { private companion object { - const val UPDATE_POSITION_INTERVAL_IN_MS = 1000L + const val UPDATE_POSITION_INTERVAL_IN_MS = 100L } init { @@ -99,6 +104,12 @@ internal constructor( extraBufferCapacity = 1 ) + private val _audioSpeed = 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 +177,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 = _audioSpeed.onStart { emit(AudioSpeed.NORMAL) } + private var currentAudioMessageId: String? = null suspend fun playAudio( @@ -190,6 +212,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) { @@ -230,16 +258,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 -> { @@ -253,38 +275,35 @@ 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( + AudioMediaPlayerStateUpdate.WaveMaskUpdate( + messageId, + wavesMaskHelper.getWaveMask(result.decodedAssetPath) + ) + ) + if (position != null) audioMediaPlayer.seekTo(position) audioMediaPlayer.start() + 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) ) } } @@ -306,8 +325,38 @@ 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 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() audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Playing) @@ -330,7 +379,13 @@ internal constructor( ) } + private suspend fun updateSpeedFlow() { + val currentSpeed = AudioSpeed.fromFloat(audioMediaPlayer.playbackParams.speed) + _audioSpeed.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 061b6c1a1b9..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 @@ -27,7 +27,9 @@ 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 @@ -37,9 +39,11 @@ 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.wrapContentWidth 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,7 +69,9 @@ 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.compose.ui.text.style.TextOverflow import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems @@ -86,7 +92,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 @@ -137,8 +143,10 @@ 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.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 @@ -163,6 +171,7 @@ import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectMessageDialo import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme 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 @@ -542,6 +551,7 @@ fun ConversationScreen( }, onAudioClick = conversationMessagesViewModel::audioClick, onChangeAudioPosition = conversationMessagesViewModel::changeAudioPosition, + onChangeAudioSpeed = conversationMessagesViewModel::changeAudioSpeed, onResetSessionClick = conversationMessagesViewModel::onResetSession, onUpdateConversationReadDate = messageComposerViewModel::updateConversationReadDate, onDropDownClick = { @@ -836,6 +846,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, @@ -920,7 +931,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, @@ -931,6 +942,7 @@ private fun ConversationScreen( onAssetItemClicked = onAssetItemClicked, onAudioItemClicked = onAudioClick, onChangeAudioPosition = onChangeAudioPosition, + onChangeAudioSpeed = onChangeAudioSpeed, onImageFullScreenMode = onImageFullScreenMode, onReactionClicked = onReactionClick, onResetSessionClicked = onResetSessionClick, @@ -997,7 +1009,7 @@ private fun ConversationScreenContent( bottomSheetVisible: Boolean, lastUnreadMessageInstant: Instant?, unreadEventCount: Int, - audioMessagesState: PersistentMap, + audioMessagesState: AudioMessagesState, assetStatuses: PersistentMap, selectedMessageId: String?, messageComposerStateHolder: MessageComposerStateHolder, @@ -1008,6 +1020,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, @@ -1055,6 +1068,7 @@ private fun ConversationScreenContent( onAssetClicked = onAssetItemClicked, onPlayAudioClicked = onAudioItemClicked, onAudioPositionChanged = onChangeAudioPosition, + onAudioSpeedChange = onChangeAudioSpeed, onImageClicked = onImageFullScreenMode, onLinkClicked = onLinkClick, onReplyClicked = onNavigateToReplyOriginalMessage, @@ -1123,7 +1137,7 @@ fun MessageList( lazyPagingMessages: LazyPagingItems, lazyListState: LazyListState, lastUnreadMessageInstant: Instant?, - audioMessagesState: PersistentMap, + audioMessagesState: AudioMessagesState, assetStatuses: PersistentMap, onUpdateConversationReadDate: (String) -> Unit, onSwipedToReply: (UIMessage.Regular) -> Unit, @@ -1237,7 +1251,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, @@ -1260,6 +1275,11 @@ fun MessageList( } } } + JumpToPlayingAudioButton( + lazyListState = lazyListState, + lazyPagingMessages = lazyPagingMessages, + playingAudiMessage = audioMessagesState.playingAudiMessage + ) JumpToLastMessageButton(lazyListState = lazyListState) } ) @@ -1394,6 +1414,64 @@ fun JumpToLastMessageButton( } } +@Composable +fun BoxScope.JumpToPlayingAudioButton( + lazyListState: LazyListState, + playingAudiMessage: PlayingAudiMessage?, + lazyPagingMessages: LazyPagingItems, + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope = rememberCoroutineScope() +) { + val indexOfPlayedMessage = playingAudiMessage?.let { + lazyPagingMessages.itemSnapshotList + .indexOfFirst { playingAudiMessage.messageId == it?.header?.messageId } + } ?: -1 + + if (indexOfPlayedMessage < 0) return + + val firstVisibleIndex = lazyListState.firstVisibleItemIndex + val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: firstVisibleIndex + + if (indexOfPlayedMessage in firstVisibleIndex..lastVisibleIndex) return + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .wrapContentWidth() + .align(Alignment.TopCenter) + .padding(all = dimensions().spacing8x) + .clickable { coroutineScope.launch { lazyListState.animateScrollToItem(indexOfPlayedMessage) } } + .background( + color = colorsScheme().secondaryText, + shape = RoundedCornerShape(dimensions().corner16x) + ) + .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x) + ) { + Icon( + modifier = Modifier.size(dimensions().systemMessageIconSize), + painter = painterResource(id = R.drawable.ic_play), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.onPrimaryButtonEnabled + ) + Text( + modifier = Modifier + .padding(horizontal = dimensions().spacing8x) + .weight(1f, fill = false), + text = playingAudiMessage!!.authorName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.body04, + ) + Text( + modifier = Modifier, + text = DateAndTimeParsers.audioMessageTime(playingAudiMessage.currentTimeMs.toLong()), + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.label03, + ) + } +} + 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 @@ -1464,5 +1542,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..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 @@ -70,6 +69,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 @@ -78,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 @@ -169,7 +167,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 + audioMessageStates.firstNotNullOfOrNull { (messageId, audioState) -> + if (audioState.audioMediaPlayingState == AudioMediaPlayingState.Playing) messageId else null + } + } + .distinctUntilChanged() + .map { messageId -> + val senderName = messageId?.let { + val result = getSenderNameByMessageId(conversationId, it) + if (result is GetSenderNameByMessageIdUseCase.Result.Success) result.name else null + } + + messageId to senderName + } + viewModelScope.launch { - conversationAudioMessagePlayer.observableAudioMessagesState.collect { - conversationViewState = conversationViewState.copy( - audioMessagesState = it.toPersistentMap() - ) + combine( + observableAudioMessagesState, + conversationAudioMessagePlayer.audioSpeed, + playingMessageData + ) { audioMessageStates, audioSpeed, (playingMessageId, playingMessageSenderName) -> + val playingAudiMessage = playingMessageId?.let { + PlayingAudiMessage( + messageId = playingMessageId, + authorName = playingMessageSenderName.orEmpty(), + currentTimeMs = audioMessageStates[playingMessageId]?.currentPositionInMs ?: 0 + ) + } + AudioMessagesState(audioMessageStates.toPersistentMap(), audioSpeed, playingAudiMessage) } + .collect { conversationViewState = conversationViewState.copy(audioMessagesState = it) } } } @@ -215,11 +253,12 @@ class ConversationMessagesViewModel @Inject constructor( } val paginatedMessagesFlow = getMessageForConversation(conversationId, lastReadIndex) + .fetchAudioWavesMaskIfNeeded() .flowOn(dispatchers.io()) conversationViewState = conversationViewState.copy( messages = paginatedMessagesFlow, - firstuUnreadEventIndex = max(lastReadIndex - 1, 0) + firstUnreadEventIndex = max(lastReadIndex - 1, 0) ) handleSelectedSearchedMessageHighlighting() @@ -397,6 +436,12 @@ class ConversationMessagesViewModel @Inject constructor( } } + fun changeAudioSpeed(audioSpeed: AudioSpeed) { + viewModelScope.launch { + conversationAudioMessagePlayer.setSpeed(audioSpeed) + } + } + fun updateImageOnFullscreenMode(message: UIMessage.Regular?) { lastImageMessageShownOnGallery = message } @@ -435,6 +480,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>.fetchAudioWavesMaskIfNeeded(): 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..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 @@ -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,25 @@ 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, + val playingAudiMessage: PlayingAudiMessage? = null +) + +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/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 a95d1fb5eb9..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 @@ -22,8 +22,10 @@ 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.PaddingValues 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 @@ -50,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 @@ -64,6 +68,9 @@ 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.DateAndTimeParsers import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -71,8 +78,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( @@ -96,8 +106,11 @@ fun AudioMessage( audioMediaPlayingState = audioMediaPlayingState, totalTimeInMs = totalTimeInMs, currentPositionInMs = currentPositionInMs, + audioSpeed = audioSpeed, + waveMask = waveMask, onPlayButtonClick = onPlayButtonClick, onSliderPositionChange = onSliderPositionChange, + onAudioSpeedChange = onAudioSpeedChange ) } } @@ -108,6 +121,7 @@ fun RecordedAudioMessage( audioMediaPlayingState: AudioMediaPlayingState, totalTimeInMs: AudioState.TotalTimeInMs, currentPositionInMs: Int, + waveMask: List, onPlayButtonClick: () -> Unit, onSliderPositionChange: (Float) -> Unit, modifier: Modifier = Modifier, @@ -123,8 +137,11 @@ fun RecordedAudioMessage( audioMediaPlayingState = audioMediaPlayingState, totalTimeInMs = totalTimeInMs, currentPositionInMs = currentPositionInMs, + audioSpeed = AudioSpeed.NORMAL, + waveMask = waveMask, onPlayButtonClick = onPlayButtonClick, onSliderPositionChange = onSliderPositionChange, + onAudioSpeedChange = null ) } } @@ -134,8 +151,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) { @@ -145,39 +165,83 @@ private fun SuccessfulAudioMessage( } Row( - modifier = modifier - .fillMaxWidth() - .height(dimensions().audioMessageHeight), - verticalAlignment = Alignment.CenterVertically + modifier = modifier.fillMaxWidth(), ) { + 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 ) - 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, + 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, + maxLines = 1 + ) + } + } } } } @@ -193,36 +257,59 @@ private fun SuccessfulAudioMessage( */ @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun RowScope.AudioMessageSlider( +private fun AudioMessageSlider( audioDuration: AudioDuration, totalTimeInMs: AudioState.TotalTimeInMs, + waveMask: List, onSliderPositionChange: (Float) -> Unit, ) { - 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().spacing20x, dimensions().spacing20x) - ) - }, - 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.weight(1f) - ) + 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 @@ -268,44 +355,28 @@ 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 +@Suppress("MagicNumber") +private fun getDefaultWaveMask(): List = List(75) { 1 } + +// 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 - } - - // 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" - } + fun formattedCurrentTime(): String = DateAndTimeParsers.audioMessageTime(currentPositionInMs.toLong()) - return UNKNOWN_DURATION_LABEL + fun formattedTotalTime(): String = if (totalDurationInMs is AudioState.TotalTimeInMs.Known) { + DateAndTimeParsers.audioMessageTime(totalDurationInMs.value.toLong()) + } else { + UNKNOWN_DURATION_LABEL } } @@ -317,8 +388,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/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)) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0239968ec0e..ee6757b6701 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -148,6 +148,8 @@ More options Add contact Image message + Pause audio + Play audio File message Ping Set timer for self-deleting messages @@ -1334,6 +1336,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..4f6dc7d8b73 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -19,11 +19,14 @@ 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 import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.auth.AccountInfo @@ -38,6 +41,8 @@ import io.mockk.impl.annotations.MockK 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") @@ -71,6 +76,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) @@ -127,6 +137,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) @@ -196,6 +211,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) @@ -231,6 +251,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) @@ -280,6 +305,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) @@ -317,6 +347,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) @@ -351,6 +386,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) @@ -407,6 +447,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) @@ -458,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) @@ -477,16 +538,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 WAVES_MASK + every { wavesMaskHelper.clear() } returns Unit } fun withCurrentSession() = apply { @@ -519,5 +587,13 @@ 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 { + val WAVES_MASK = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 0) + } } 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/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index bc48d1c4fe4..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 @@ -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 @@ -46,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 @@ -115,6 +117,9 @@ class ConversationMessagesViewModelArrangement { @MockK lateinit var deleteMessage: DeleteMessageUseCase + @MockK + lateinit var getSenderNameByMessageId: GetSenderNameByMessageIdUseCase + private val viewModel: ConversationMessagesViewModel by lazy { ConversationMessagesViewModel( savedStateHandle, @@ -132,7 +137,8 @@ class ConversationMessagesViewModelArrangement { getConversationUnreadEventsCount, clearUsersTypingEvents, getSearchedConversationMessagePosition, - deleteMessage + deleteMessage, + getSenderNameByMessageId ) } @@ -154,6 +160,10 @@ 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 + coEvery { getSenderNameByMessageId(any(), any()) } returns GetSenderNameByMessageIdUseCase.Result.Success("User Name") } fun withSuccessfulViewModelInit() = apply { @@ -227,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 157d0d0196e..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 @@ -20,23 +20,33 @@ 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.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.android.ui.home.conversations.composer.mockUITextMessage +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 +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.Path.Companion.toPath import org.amshove.kluent.internal.assertEquals @@ -53,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() @@ -77,7 +87,7 @@ class ConversationMessagesViewModelTest { ) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() .withGetMessageByIdReturning(message) - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withGetMessageAssetUseCaseReturning(assetDataPath, assetSize) .withSuccessfulOpenAssetMessage(assetMimeType, assetName, assetDataPath, assetSize, messageId) .arrange() @@ -105,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) @@ -129,7 +139,7 @@ class ConversationMessagesViewModelTest { val updatedPagingData = PagingData.from(listOf(secondMessage)) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .arrange() viewModel.conversationViewState.messages.test { @@ -143,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" @@ -160,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) } @@ -170,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) } @@ -181,7 +191,7 @@ class ConversationMessagesViewModelTest { val userId = UserId("someID", "someDomain") val clientId = "someClientId" val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withResetSessionResult() .arrange() @@ -298,4 +308,121 @@ 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() + } + + @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/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/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 d88a5b59776..fdf99a843c7 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 @@ -362,7 +362,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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6dbdf6c2991..866ab3393b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ androidx-biometric = "1.1.0" androidx-startup = "1.2.0" androidx-compose-runtime = "1.7.2" compose-qr = "1.0.1" +amplituda = "2.2.2" # Compose composeBom = "2024.11.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" } diff --git a/kalium b/kalium index d168aa2c17c..6ddef736ac6 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit d168aa2c17c460263910294b80f9ca402baaf4cb +Subproject commit 6ddef736ac6399a4e849daa618bc941591e859ea